mirror of
https://github.com/documenso/documenso.git
synced 2025-11-25 22:21:31 +10:00
Compare commits
1 Commits
feat/organ
...
chore/dece
| Author | SHA1 | Date | |
|---|---|---|---|
| 5dbbed9ba8 |
59
.cursorrules
59
.cursorrules
@ -1,7 +1,4 @@
|
|||||||
You are an expert in TypeScript, Node.js, Remix, React, Shadcn UI and Tailwind.
|
|
||||||
|
|
||||||
Code Style and Structure:
|
Code Style and Structure:
|
||||||
|
|
||||||
- Write concise, technical TypeScript code with accurate examples
|
- Write concise, technical TypeScript code with accurate examples
|
||||||
- Use functional and declarative programming patterns; avoid classes
|
- Use functional and declarative programming patterns; avoid classes
|
||||||
- Prefer iteration and modularization over code duplication
|
- Prefer iteration and modularization over code duplication
|
||||||
@ -9,25 +6,20 @@ Code Style and Structure:
|
|||||||
- Structure files: exported component, subcomponents, helpers, static content, types
|
- Structure files: exported component, subcomponents, helpers, static content, types
|
||||||
|
|
||||||
Naming Conventions:
|
Naming Conventions:
|
||||||
|
|
||||||
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
|
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
|
||||||
- Favor named exports for components
|
- Favor named exports for components
|
||||||
|
|
||||||
TypeScript Usage:
|
TypeScript Usage:
|
||||||
|
- Use TypeScript for all code; prefer interfaces over types
|
||||||
- Use TypeScript for all code; prefer types over interfaces
|
- Avoid enums; use maps instead
|
||||||
- Use functional components with TypeScript interfaces
|
- Use functional components with TypeScript interfaces
|
||||||
|
|
||||||
Syntax and Formatting:
|
Syntax and Formatting:
|
||||||
|
- Use the "function" keyword for pure functions
|
||||||
- Create functions using `const fn = () => {}`
|
|
||||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
|
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
|
||||||
- Use declarative JSX
|
- Use declarative JSX
|
||||||
- Never use 'use client'
|
|
||||||
- Never use 1 line if statements
|
|
||||||
|
|
||||||
Error Handling and Validation:
|
Error Handling and Validation:
|
||||||
|
|
||||||
- Prioritize error handling: handle errors and edge cases early
|
- Prioritize error handling: handle errors and edge cases early
|
||||||
- Use early returns and guard clauses
|
- Use early returns and guard clauses
|
||||||
- Implement proper error logging and user-friendly messages
|
- Implement proper error logging and user-friendly messages
|
||||||
@ -36,40 +28,21 @@ Error Handling and Validation:
|
|||||||
- Use error boundaries for unexpected errors
|
- Use error boundaries for unexpected errors
|
||||||
|
|
||||||
UI and Styling:
|
UI and Styling:
|
||||||
|
|
||||||
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
|
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
|
||||||
- Implement responsive design with Tailwind CSS; use a mobile-first approach
|
- Implement responsive design with Tailwind CSS; use a mobile-first approach
|
||||||
- When using Lucide icons, prefer the longhand names, for example HomeIcon instead of Home
|
|
||||||
|
|
||||||
React forms
|
Performance Optimization:
|
||||||
|
- Minimize 'use client', 'useEffect', and 'setState'; favor React Server Components (RSC)
|
||||||
|
- Wrap client components in Suspense with fallback
|
||||||
|
- Use dynamic loading for non-critical components
|
||||||
|
- Optimize images: use WebP format, include size data, implement lazy loading
|
||||||
|
|
||||||
- Use zod for form validation react-hook-form for forms
|
Key Conventions:
|
||||||
- Look at TeamCreateDialog.tsx as an example of form usage
|
- Use 'nuqs' for URL search parameter state management
|
||||||
- Use <Form> <FormItem> elements, and also wrap the contents of form in a fieldset which should have the :disabled attribute when the form is loading
|
- Optimize Web Vitals (LCP, CLS, FID)
|
||||||
|
- Limit 'use client':
|
||||||
|
- Favor server components and Next.js SSR
|
||||||
|
- Use only for Web API access in small components
|
||||||
|
- Avoid for data fetching or state management
|
||||||
|
|
||||||
TRPC Specifics
|
Follow Next.js docs for Data Fetching, Rendering, and Routing
|
||||||
|
|
||||||
- Every route should be in it's own file, example routers/teams/create-team.ts
|
|
||||||
- Every route should have a types file associated with it, example routers/teams/create-team.types.ts. These files should have the OpenAPI meta, and request/response zod schemas
|
|
||||||
- The request/response schemas should be named like Z[RouteName]RequestSchema and Z[RouteName]ResponseSchema
|
|
||||||
- Use create-team.ts and create-team.types.ts as an example when creating new routes.
|
|
||||||
- When creating the OpenAPI meta, only use GET and POST requests, do not use any other REST methods
|
|
||||||
- Deconstruct the input argument on it's one line of code.
|
|
||||||
|
|
||||||
Toast usage
|
|
||||||
|
|
||||||
- Use the t`string` macro from @lingui/react/macro to display toast messages
|
|
||||||
|
|
||||||
Remix/ReactRouter Usage
|
|
||||||
|
|
||||||
- Use (params: Route.Params) to get the params from the route
|
|
||||||
- Use (loaderData: Route.LoaderData) to get the loader data from the route
|
|
||||||
- When using loaderdata, deconstruct the data you need from the loader data inside the function body
|
|
||||||
- Do not use json() to return data, directly return the data
|
|
||||||
|
|
||||||
Translations
|
|
||||||
|
|
||||||
- Use <Trans>string</Trans> to display translations in jsx code, this should be imported from @lingui/react/macro
|
|
||||||
- Use the t`string` macro from @lingui/react/macro to display translations in typescript code
|
|
||||||
- t should be imported as const { t } = useLingui() where useLingui is imported from @lingui/react/macro
|
|
||||||
- String in constants should be using the t`string` macro
|
|
||||||
14
.env.example
14
.env.example
@ -1,4 +1,5 @@
|
|||||||
# [[AUTH]]
|
# [[AUTH]]
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="secret"
|
NEXTAUTH_SECRET="secret"
|
||||||
|
|
||||||
# [[CRYPTO]]
|
# [[CRYPTO]]
|
||||||
@ -18,10 +19,14 @@ NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
|||||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
||||||
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
|
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=""
|
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
||||||
|
|
||||||
# [[URLS]]
|
# [[URLS]]
|
||||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||||
|
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||||
# URL used by the web app to request itself (e.g. local background jobs)
|
# URL used by the web app to request itself (e.g. local background jobs)
|
||||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||||
|
|
||||||
@ -108,9 +113,13 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
|||||||
# [[STRIPE]]
|
# [[STRIPE]]
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||||
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||||
|
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
|
||||||
|
|
||||||
# [[BACKGROUND JOBS]]
|
# [[BACKGROUND JOBS]]
|
||||||
NEXT_PRIVATE_JOBS_PROVIDER="local"
|
NEXT_PRIVATE_JOBS_PROVIDER="local"
|
||||||
|
NEXT_PRIVATE_TRIGGER_API_KEY=
|
||||||
|
NEXT_PRIVATE_TRIGGER_API_URL=
|
||||||
NEXT_PRIVATE_INNGEST_EVENT_KEY=
|
NEXT_PRIVATE_INNGEST_EVENT_KEY=
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
@ -126,5 +135,10 @@ E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
|||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
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]]
|
# [[LOGGER]]
|
||||||
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
|
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
|
||||||
|
|||||||
@ -5,7 +5,6 @@ module.exports = {
|
|||||||
rules: {
|
rules: {
|
||||||
'@next/next/no-img-element': 'off',
|
'@next/next/no-img-element': 'off',
|
||||||
'no-unreachable': 'error',
|
'no-unreachable': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'off',
|
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
next: {
|
next: {
|
||||||
|
|||||||
23
.github/actions/cache-build/action.yml
vendored
Normal file
23
.github/actions/cache-build/action.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
name: Cache production build binaries
|
||||||
|
description: 'Cache or restore if necessary'
|
||||||
|
inputs:
|
||||||
|
node_version:
|
||||||
|
required: false
|
||||||
|
default: v20.x
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Cache production build
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: production-build-cache
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ github.workspace }}/apps/web/.next
|
||||||
|
**/.turbo/**
|
||||||
|
**/dist/**
|
||||||
|
|
||||||
|
key: prod-build-${{ github.run_id }}-${{ hashFiles('package-lock.json') }}
|
||||||
|
restore-keys: prod-build-
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
|
shell: bash
|
||||||
2
.github/actions/node-install/action.yml
vendored
2
.github/actions/node-install/action.yml
vendored
@ -2,7 +2,7 @@ name: 'Setup node and cache node_modules'
|
|||||||
inputs:
|
inputs:
|
||||||
node_version:
|
node_version:
|
||||||
required: false
|
required: false
|
||||||
default: v22.x
|
default: v20.x
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
|
|||||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -26,8 +26,7 @@ jobs:
|
|||||||
- name: Copy env
|
- name: Copy env
|
||||||
run: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|
||||||
- name: Build app
|
- uses: ./.github/actions/cache-build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
build_docker:
|
build_docker:
|
||||||
name: Build Docker Image
|
name: Build Docker Image
|
||||||
|
|||||||
29
.github/workflows/clean-cache.yml
vendored
Normal file
29
.github/workflows/clean-cache.yml
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
name: cleanup caches by a branch
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- closed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Cleanup
|
||||||
|
run: |
|
||||||
|
gh extension install actions/gh-actions-cache
|
||||||
|
|
||||||
|
echo "Fetching list of cache key"
|
||||||
|
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
|
||||||
|
|
||||||
|
## Setting this to not fail the workflow while deleting cache keys.
|
||||||
|
set +e
|
||||||
|
echo "Deleting caches..."
|
||||||
|
for cacheKey in $cacheKeysForPR
|
||||||
|
do
|
||||||
|
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
|
||||||
|
done
|
||||||
|
echo "Done"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
|
||||||
3
.github/workflows/codeql-analysis.yml
vendored
3
.github/workflows/codeql-analysis.yml
vendored
@ -30,8 +30,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/node-install
|
- uses: ./.github/actions/node-install
|
||||||
|
|
||||||
- name: Build app
|
- uses: ./.github/actions/cache-build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v3
|
||||||
|
|||||||
7
.github/workflows/e2e-tests.yml
vendored
7
.github/workflows/e2e-tests.yml
vendored
@ -1,14 +1,14 @@
|
|||||||
name: Playwright Tests
|
name: Playwright Tests
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main', 'feat/rr7']
|
branches: ['main']
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
jobs:
|
jobs:
|
||||||
e2e_tests:
|
e2e_tests:
|
||||||
name: 'E2E Tests'
|
name: 'E2E Tests'
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: warp-ubuntu-2204-x64-16x
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@ -28,8 +28,7 @@ jobs:
|
|||||||
- name: Seed the database
|
- name: Seed the database
|
||||||
run: npm run prisma:seed
|
run: npm run prisma:seed
|
||||||
|
|
||||||
- name: Build app
|
- uses: ./.github/actions/cache-build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
run: npm run ci
|
run: npm run ci
|
||||||
|
|||||||
@ -4,7 +4,9 @@ tasks:
|
|||||||
npm run dx:up &&
|
npm run dx:up &&
|
||||||
cp .env.example .env &&
|
cp .env.example .env &&
|
||||||
set -a; source .env &&
|
set -a; source .env &&
|
||||||
|
export NEXTAUTH_URL="$(gp url 3000)" &&
|
||||||
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
||||||
|
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
||||||
command: npm run d
|
command: npm run d
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -4,9 +4,12 @@
|
|||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||||
|
|
||||||
|
echo "Copying pdf.js"
|
||||||
|
npm run copy:pdfjs --workspace apps/**
|
||||||
|
|
||||||
echo "Copying .well-known/ contents"
|
echo "Copying .well-known/ contents"
|
||||||
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
||||||
|
|
||||||
git add "$MONOREPO_ROOT/apps/remix/public/"
|
git add "$MONOREPO_ROOT/apps/web/public/"
|
||||||
|
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|||||||
21
README.md
21
README.md
@ -1,3 +1,7 @@
|
|||||||
|
> 🚨 We are live on Product Hunt 🎉 Check out our latest launch: <a href="documen.so/sign-everywhere">The Platform Plan</a>!
|
||||||
|
|
||||||
|
<a href="https://www.producthunt.com/posts/documenso-platform-plan?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-documenso-platform-plan" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=670576&theme=light" alt="Documenso Platform Plan - Whitelabeled signing flows in your 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">
|
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||||
|
|
||||||
<p align="center" style="margin-top: 20px">
|
<p align="center" style="margin-top: 20px">
|
||||||
@ -69,9 +73,9 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
|
<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
|
## Tech Stack
|
||||||
|
|
||||||
<p align="left">
|
<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://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://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="https://tailwindcss.com/"><img src="https://img.shields.io/badge/tailwindcss-0F172A?&logo=tailwindcss" alt="Tailwind CSS"></a>
|
||||||
<a href=""><img src="" alt=""></a>
|
<a href=""><img src="" alt=""></a>
|
||||||
@ -81,17 +85,20 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
<a href=""><img src="" alt=""></a>
|
<a href=""><img src="" alt=""></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
- [Typescript](https://www.typescriptlang.org/) - Language
|
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||||
- [ReactRouter](https://reactrouter.com/) - Framework
|
- [Next.js](https://nextjs.org/) - Framework
|
||||||
- [Prisma](https://www.prisma.io/) - ORM
|
- [Prisma](https://www.prisma.io/) - ORM
|
||||||
- [Tailwind](https://tailwindcss.com/) - CSS
|
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||||
|
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
||||||
- [react-email](https://react.email/) - Email Templates
|
- [react-email](https://react.email/) - Email Templates
|
||||||
- [tRPC](https://trpc.io/) - API
|
- [tRPC](https://trpc.io/) - API
|
||||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
|
- [@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
|
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||||
- [Stripe](https://stripe.com/) - Payments
|
- [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. -->
|
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
|
||||||
|
|
||||||
@ -101,7 +108,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
|||||||
|
|
||||||
To run Documenso locally, you will need
|
To run Documenso locally, you will need
|
||||||
|
|
||||||
- Node.js (v22 or above)
|
- Node.js (v18 or above)
|
||||||
- Postgres SQL Database
|
- Postgres SQL Database
|
||||||
- Docker (optional)
|
- Docker (optional)
|
||||||
|
|
||||||
@ -164,8 +171,10 @@ git clone https://github.com/<your-username>/documenso
|
|||||||
|
|
||||||
4. Set the following environment variables:
|
4. Set the following environment variables:
|
||||||
|
|
||||||
|
- NEXTAUTH_URL
|
||||||
- NEXTAUTH_SECRET
|
- NEXTAUTH_SECRET
|
||||||
- NEXT_PUBLIC_WEBAPP_URL
|
- NEXT_PUBLIC_WEBAPP_URL
|
||||||
|
- NEXT_PUBLIC_MARKETING_URL
|
||||||
- NEXT_PRIVATE_DATABASE_URL
|
- NEXT_PRIVATE_DATABASE_URL
|
||||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
@ -234,14 +243,16 @@ cp .env.example .env
|
|||||||
|
|
||||||
The following environment variables must be set:
|
The following environment variables must be set:
|
||||||
|
|
||||||
|
- `NEXTAUTH_URL`
|
||||||
- `NEXTAUTH_SECRET`
|
- `NEXTAUTH_SECRET`
|
||||||
- `NEXT_PUBLIC_WEBAPP_URL`
|
- `NEXT_PUBLIC_WEBAPP_URL`
|
||||||
|
- `NEXT_PUBLIC_MARKETING_URL`
|
||||||
- `NEXT_PRIVATE_DATABASE_URL`
|
- `NEXT_PRIVATE_DATABASE_URL`
|
||||||
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
||||||
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
||||||
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
|
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
|
||||||
|
|
||||||
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
|
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for both `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
|
||||||
|
|
||||||
Now you can install the dependencies and build it:
|
Now you can install the dependencies and build it:
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3002",
|
"start": "next start -p 3002",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules"
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/assets": "*",
|
"@documenso/assets": "*",
|
||||||
@ -15,7 +16,7 @@
|
|||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"next": "14.2.6",
|
"next": "14.2.23",
|
||||||
"next-plausible": "^3.12.0",
|
"next-plausible": "^3.12.0",
|
||||||
"nextra": "^2.13.4",
|
"nextra": "^2.13.4",
|
||||||
"nextra-theme-docs": "^2.13.4",
|
"nextra-theme-docs": "^2.13.4",
|
||||||
@ -26,6 +27,6 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"typescript": "5.6.2"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -14,4 +14,4 @@
|
|||||||
"public-api": "Public API",
|
"public-api": "Public API",
|
||||||
"embedding": "Embedding",
|
"embedding": "Embedding",
|
||||||
"webhooks": "Webhooks"
|
"webhooks": "Webhooks"
|
||||||
}
|
}
|
||||||
@ -5,6 +5,5 @@
|
|||||||
"svelte": "Svelte Integration",
|
"svelte": "Svelte Integration",
|
||||||
"solid": "Solid Integration",
|
"solid": "Solid Integration",
|
||||||
"preact": "Preact Integration",
|
"preact": "Preact Integration",
|
||||||
"angular": "Angular Integration",
|
|
||||||
"css-variables": "CSS Variables"
|
"css-variables": "CSS Variables"
|
||||||
}
|
}
|
||||||
@ -1,90 +0,0 @@
|
|||||||
---
|
|
||||||
title: Angular Integration
|
|
||||||
description: Learn how to use our embedding SDK within your Angular application.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Angular Integration
|
|
||||||
|
|
||||||
Our Angular SDK provides a simple way to embed a signing experience within your Angular application. It supports both direct link templates and signing tokens.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
To install the SDK, run the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install @documenso/embed-angular
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
To embed a signing experience, you'll need to provide the token for the document you want to embed. This can be done in a few different ways, depending on your use case.
|
|
||||||
|
|
||||||
### Direct Link Template
|
|
||||||
|
|
||||||
If you have a direct link template, you can simply provide the token for the template to the `EmbedDirectTemplate` component.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Component } from '@angular/core';
|
|
||||||
import { EmbedDirectTemplate } from '@documenso/embed-angular';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-embedding',
|
|
||||||
template: `
|
|
||||||
<embed-direct-template [token]="token" />
|
|
||||||
`,
|
|
||||||
standalone: true,
|
|
||||||
imports: [EmbedDirectTemplate],
|
|
||||||
})
|
|
||||||
export class EmbeddingComponent {
|
|
||||||
token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Props
|
|
||||||
|
|
||||||
| Prop | Type | Description |
|
|
||||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| token | string | The token for the document you want to embed |
|
|
||||||
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
|
||||||
| name | string (optional) | The name the signer that will be used by default for signing |
|
|
||||||
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
|
||||||
| email | string (optional) | The email the signer that will be used by default for signing |
|
|
||||||
| lockEmail | boolean (optional) | Whether or not the email field should be locked disallowing modifications |
|
|
||||||
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
|
||||||
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
|
||||||
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
|
||||||
| onFieldSigned | function (optional) | A callback function that will be called when a field is signed |
|
|
||||||
| onFieldUnsigned | function (optional) | A callback function that will be called when a field is unsigned |
|
|
||||||
|
|
||||||
### Signing Token
|
|
||||||
|
|
||||||
If you have a signing token, you can provide it to the `EmbedSignDocument` component.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Component } from '@angular/core';
|
|
||||||
import { EmbedSignDocument } from '@documenso/embed-angular';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-embedding',
|
|
||||||
template: `
|
|
||||||
<embed-sign-document [token]="token" />
|
|
||||||
`,
|
|
||||||
standalone: true,
|
|
||||||
imports: [EmbedSignDocument],
|
|
||||||
})
|
|
||||||
export class EmbeddingComponent {
|
|
||||||
token = 'YOUR_TOKEN_HERE'; // Replace with the actual token
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Props
|
|
||||||
|
|
||||||
| Prop | Type | Description |
|
|
||||||
| ------------------- | ------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| token | string | The token for the document you want to embed |
|
|
||||||
| host | string (optional) | The host to be used for the signing experience, relevant for self-hosters |
|
|
||||||
| name | string (optional) | The name the signer that will be used by default for signing |
|
|
||||||
| lockName | boolean (optional) | Whether or not the name field should be locked disallowing modifications |
|
|
||||||
| onDocumentReady | function (optional) | A callback function that will be called when the document is loaded and ready to be signed |
|
|
||||||
| onDocumentCompleted | function (optional) | A callback function that will be called when the document has been completed |
|
|
||||||
| onDocumentError | function (optional) | A callback function that will be called when an error occurs with the document |
|
|
||||||
@ -111,83 +111,6 @@ The colors will be automatically converted to the appropriate format internally.
|
|||||||
|
|
||||||
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
||||||
|
|
||||||
## CSS Class Targets
|
|
||||||
|
|
||||||
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
|
|
||||||
|
|
||||||
### Component Classes
|
|
||||||
|
|
||||||
| Class Name | Description |
|
|
||||||
| --------------------------------- | ----------------------------------------------------------------------- |
|
|
||||||
| `.embed--Root` | Main container for the embedded signing experience |
|
|
||||||
| `.embed--DocumentContainer` | Container for the document and signing widget |
|
|
||||||
| `.embed--DocumentViewer` | Container for the document viewer |
|
|
||||||
| `.embed--DocumentWidget` | The signing widget container |
|
|
||||||
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
|
|
||||||
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
|
|
||||||
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
|
|
||||||
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
|
|
||||||
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
|
|
||||||
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
|
|
||||||
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
|
|
||||||
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
|
|
||||||
|
|
||||||
Field components also expose several data attributes that can be used for styling different states:
|
|
||||||
|
|
||||||
| Data Attribute | Values | Description |
|
|
||||||
| ------------------- | ---------------------------------------------- | ------------------------------------ |
|
|
||||||
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
|
|
||||||
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
|
|
||||||
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
|
|
||||||
|
|
||||||
### Field Styling Example
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Style all field containers */
|
|
||||||
.field--FieldRootContainer {
|
|
||||||
transition: all 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style specific field types */
|
|
||||||
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
|
|
||||||
background-color: rgba(0, 0, 0, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style inserted fields */
|
|
||||||
.field--FieldRootContainer[data-inserted='true'] {
|
|
||||||
background-color: var(--primary);
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style fields being validated */
|
|
||||||
.field--FieldRootContainer[data-validate='true'] {
|
|
||||||
border-color: orange;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Usage
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* Custom styles for the document widget */
|
|
||||||
.embed--DocumentWidget {
|
|
||||||
background-color: #ffffff;
|
|
||||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom styles for the waiting screen */
|
|
||||||
.embed--WaitingForTurn {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments for the document container */
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.embed--DocumentContainer {
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [React Integration](/developers/embedding/react)
|
- [React Integration](/developers/embedding/react)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ description: Learn how to use embedding to bring signing to your own website or
|
|||||||
|
|
||||||
# Embedding
|
# Embedding
|
||||||
|
|
||||||
Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, Angular, or using generalized web components, this guide will help you get started with embedding Documenso.
|
Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, or using generalized web components, this guide will help you get started with embedding Documenso.
|
||||||
|
|
||||||
## Availability
|
## Availability
|
||||||
|
|
||||||
@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
|
|||||||
<EmbedDirectTemplate
|
<EmbedDirectTemplate
|
||||||
token={token}
|
token={token}
|
||||||
cssVars={{
|
cssVars={{
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
@ -73,14 +73,13 @@ These customization options are available for both Direct Templates and Signing
|
|||||||
|
|
||||||
We support embedding across a range of popular JavaScript frameworks, including:
|
We support embedding across a range of popular JavaScript frameworks, including:
|
||||||
|
|
||||||
| Framework | Package |
|
| Framework | Package |
|
||||||
| --------- | ---------------------------------------------------------------------------------- |
|
| --------- | -------------------------------------------------------------------------------- |
|
||||||
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
| React | [@documenso/embed-react](https://www.npmjs.com/package/@documenso/embed-react) |
|
||||||
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
| Preact | [@documenso/embed-preact](https://www.npmjs.com/package/@documenso/embed-preact) |
|
||||||
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
| Vue | [@documenso/embed-vue](https://www.npmjs.com/package/@documenso/embed-vue) |
|
||||||
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
| Svelte | [@documenso/embed-svelte](https://www.npmjs.com/package/@documenso/embed-svelte) |
|
||||||
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
| Solid | [@documenso/embed-solid](https://www.npmjs.com/package/@documenso/embed-solid) |
|
||||||
| Angular | [@documenso/embed-angular](https://www.npmjs.com/package/@documenso/embed-angular) |
|
|
||||||
|
|
||||||
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
|
Additionally, we provide **web components** for more generalized use. However, please note that web components are still in their early stages and haven't been extensively tested.
|
||||||
|
|
||||||
@ -128,7 +127,7 @@ This will show a dialog which will ask you to configure which recipient should b
|
|||||||
|
|
||||||
## Embedding with Signing Tokens
|
## Embedding with Signing Tokens
|
||||||
|
|
||||||
To embed the signing process for an ordinary document, you'll need a **document signing token** for the recipient. This token provides the necessary access to load the document and facilitate the signing process securely.
|
To embed the signing process for an ordinary document, you’ll need a **document signing token** for the recipient. This token provides the necessary access to load the document and facilitate the signing process securely.
|
||||||
|
|
||||||
#### Instructions
|
#### Instructions
|
||||||
|
|
||||||
@ -165,7 +164,6 @@ Once you've obtained the appropriate tokens, you can integrate the signing exper
|
|||||||
- [Vue](/developers/embedding/vue)
|
- [Vue](/developers/embedding/vue)
|
||||||
- [Svelte](/developers/embedding/svelte)
|
- [Svelte](/developers/embedding/svelte)
|
||||||
- [Solid](/developers/embedding/solid)
|
- [Solid](/developers/embedding/solid)
|
||||||
- [Angular](/developers/embedding/angular)
|
|
||||||
|
|
||||||
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
If you're using **web components**, the integration process is slightly different. Keep in mind that web components are currently less tested but can still provide flexibility for general use cases.
|
||||||
|
|
||||||
@ -176,5 +174,4 @@ If you're using **web components**, the integration process is slightly differen
|
|||||||
- [Svelte Integration](/developers/embedding/svelte)
|
- [Svelte Integration](/developers/embedding/svelte)
|
||||||
- [Solid Integration](/developers/embedding/solid)
|
- [Solid Integration](/developers/embedding/solid)
|
||||||
- [Preact Integration](/developers/embedding/preact)
|
- [Preact Integration](/developers/embedding/preact)
|
||||||
- [Angular Integration](/developers/embedding/angular)
|
|
||||||
- [CSS Variables](/developers/embedding/css-variables)
|
- [CSS Variables](/developers/embedding/css-variables)
|
||||||
|
|||||||
@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
`}
|
`}
|
||||||
// CSS Variables
|
// CSS Variables
|
||||||
cssVars={{
|
cssVars={{
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
}}
|
}}
|
||||||
// Dark Mode Control
|
// Dark Mode Control
|
||||||
darkModeDisabled={true}
|
darkModeDisabled={true}
|
||||||
|
|||||||
@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const cssVars = {
|
const cssVars = {
|
||||||
primary: '#0000FF',
|
colorPrimary: '#0000FF',
|
||||||
background: '#F5F5F5',
|
colorBackground: '#F5F5F5',
|
||||||
radius: '8px',
|
borderRadius: '8px',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -16,16 +16,18 @@ Pick the one that fits your needs the best.
|
|||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- [Typescript](https://www.typescriptlang.org/) - Language
|
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||||
- [React Router](https://reactrouter.com/) - Framework
|
- [Next.js](https://nextjs.org/) - Framework
|
||||||
- [Prisma](https://www.prisma.io/) - ORM
|
- [Prisma](https://www.prisma.io/) - ORM
|
||||||
- [Tailwind](https://tailwindcss.com/) - CSS
|
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||||
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||||
|
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
||||||
- [react-email](https://react.email/) - Email Templates
|
- [react-email](https://react.email/) - Email Templates
|
||||||
- [tRPC](https://trpc.io/) - API
|
- [tRPC](https://trpc.io/) - API
|
||||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
||||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||||
- [Stripe](https://stripe.com/) - Payments
|
- [Stripe](https://stripe.com/) - Payments
|
||||||
|
- [Vercel](https://vercel.com) - Hosting
|
||||||
|
|
||||||
<div className="mt-16 flex items-center justify-center gap-4">
|
<div className="mt-16 flex items-center justify-center gap-4">
|
||||||
<a href="https://documen.so/discord">
|
<a href="https://documen.so/discord">
|
||||||
|
|||||||
@ -32,8 +32,10 @@ Run `npm i` in the root directory to install the dependencies required for the p
|
|||||||
Set up the following environment variables in the `.env` file:
|
Set up the following environment variables in the `.env` file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
NEXTAUTH_URL
|
||||||
NEXTAUTH_SECRET
|
NEXTAUTH_SECRET
|
||||||
NEXT_PUBLIC_WEBAPP_URL
|
NEXT_PUBLIC_WEBAPP_URL
|
||||||
|
NEXT_PUBLIC_MARKETING_URL
|
||||||
NEXT_PRIVATE_DATABASE_URL
|
NEXT_PRIVATE_DATABASE_URL
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL
|
NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
NEXT_PRIVATE_SMTP_FROM_NAME
|
NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
|
|||||||
@ -13,13 +13,35 @@ Documenso uses the following stack to handle translations:
|
|||||||
|
|
||||||
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
|
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
You **must** insert **`setupI18nSSR()`** when creating any of the following files:
|
||||||
|
|
||||||
|
- Server layout.tsx
|
||||||
|
- Server page.tsx
|
||||||
|
- Server loading.tsx
|
||||||
|
|
||||||
|
Server meaning it does not have `'use client'` in it.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||||
|
|
||||||
|
export default function SomePage() {
|
||||||
|
setupI18nSSR(); // Required if there are translations within the page, or nested in components.
|
||||||
|
|
||||||
|
// Rest of code...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui)
|
||||||
|
|
||||||
## Quick guide
|
## Quick guide
|
||||||
|
|
||||||
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
|
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
|
||||||
|
|
||||||
### HTML
|
### HTML
|
||||||
|
|
||||||
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/react/macro**.
|
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<h1>
|
<h1>
|
||||||
@ -42,9 +64,8 @@ For text that is broken into elements, but represent a whole sentence, you must
|
|||||||
### Constants outside of react components
|
### Constants outside of react components
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { msg } from '@lingui/core/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
|
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
|
||||||
export const CONSTANT_WITH_MSG = {
|
export const CONSTANT_WITH_MSG = {
|
||||||
@ -77,13 +98,31 @@ Lingui provides a Plural component to make it easy. See full documentation [here
|
|||||||
|
|
||||||
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
|
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
|
||||||
|
|
||||||
|
#### Server components
|
||||||
|
|
||||||
|
Note that the i18n instance is coming from **setupI18nSSR**.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
export const SomeComponent = () => {
|
export const SomeComponent = () => {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = setupI18nSSR();
|
||||||
|
|
||||||
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Client components
|
||||||
|
|
||||||
|
Note that the i18n instance is coming from the **import**.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { i18n } from '@lingui/core';
|
||||||
|
import { Trans } from '@lingui/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
export const SomeComponent = () => {
|
||||||
|
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|||||||
@ -3,8 +3,6 @@ title: Public API
|
|||||||
description: Learn how to interact with your documents programmatically using the Documenso public API.
|
description: Learn how to interact with your documents programmatically using the Documenso public API.
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Callout, Steps } from 'nextra/components';
|
|
||||||
|
|
||||||
# Public API
|
# Public API
|
||||||
|
|
||||||
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
|
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
|
||||||
@ -15,35 +13,10 @@ Documenso provides a public REST API enabling you to interact with your document
|
|||||||
|
|
||||||
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
|
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
|
||||||
|
|
||||||
## API V1 - Stable
|
## Swagger Documentation
|
||||||
|
|
||||||
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
|
||||||
|
|
||||||
## API V2 - Beta
|
|
||||||
|
|
||||||
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
|
||||||
|
|
||||||
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
|
||||||
|
|
||||||
Our new API V2 supports the following typed SDKs:
|
|
||||||
|
|
||||||
- [TypeScript](https://github.com/documenso/sdk-typescript)
|
|
||||||
- [Python](https://github.com/documenso/sdk-python)
|
|
||||||
- [Go](https://github.com/documenso/sdk-go)
|
|
||||||
|
|
||||||
<Callout type="info">
|
|
||||||
For the staging API, please use the following base URL:
|
|
||||||
`https://stg-app.documenso.dev/api/v2-beta/`
|
|
||||||
</Callout>
|
|
||||||
|
|
||||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
|
||||||
|
|
||||||
📖 [Documentation](https://documen.so/api-v2-docs)
|
|
||||||
|
|
||||||
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
|
||||||
|
|
||||||
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
|
||||||
|
|
||||||
## Availability
|
## Availability
|
||||||
|
|
||||||
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.
|
The API is available to individual users and teams.
|
||||||
|
|||||||
@ -532,93 +532,3 @@ Replace the `text` value with the corresponding field type:
|
|||||||
- For the `SELECT` field it should be `select`. (check this before merge)
|
- For the `SELECT` field it should be `select`. (check this before merge)
|
||||||
|
|
||||||
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
You must pass this property at all times, even if you don't need to set any other properties. If you don't, the endpoint will throw an error.
|
||||||
|
|
||||||
## Pre-fill Fields On Document Creation
|
|
||||||
|
|
||||||
The API allows you to pre-fill fields on document creation. This is useful when you want to create a document from an existing template and pre-fill the fields with specific values.
|
|
||||||
|
|
||||||
To pre-fill a field, you need to make a `POST` request to the `/api/v1/templates/{templateId}/generate-document` endpoint with the field information. Here's an example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"title": "my-document.pdf",
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Example User",
|
|
||||||
"email": "example@documenso.com",
|
|
||||||
"signingOrder": 1,
|
|
||||||
"role": "SIGNER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"prefillFields": [
|
|
||||||
{
|
|
||||||
"id": 21,
|
|
||||||
"type": "text",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "my-value"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 22,
|
|
||||||
"type": "number",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 23,
|
|
||||||
"type": "checkbox",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": ["option-1", "option-2"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Check out the endpoint in the [API V1 documentation](https://app.documenso.com/api/v1/openapi#:~:text=/%7BtemplateId%7D/-,generate,-%2Ddocument).
|
|
||||||
|
|
||||||
### API V2
|
|
||||||
|
|
||||||
For API V2, you need to make a `POST` request to the `/api/v2-beta/template/use` endpoint with the field(s) information. Here's an example:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"templateId": 111,
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"name": "Example User",
|
|
||||||
"email": "example@documenso.com",
|
|
||||||
"signingOrder": 1,
|
|
||||||
"role": "SIGNER"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"prefillFields": [
|
|
||||||
{
|
|
||||||
"id": 21,
|
|
||||||
"type": "text",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "my-value"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 22,
|
|
||||||
"type": "number",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": "123"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 23,
|
|
||||||
"type": "checkbox",
|
|
||||||
"label": "my-label",
|
|
||||||
"placeholder": "my-placeholder",
|
|
||||||
"value": ["option-1", "option-2"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Check out the endpoint in the [API V2 documentation](https://openapi.documenso.com/reference#tag/template/POST/template/use).
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ description: Learn how to self-host Documenso on your server or cloud infrastruc
|
|||||||
|
|
||||||
import { Callout, Steps } from 'nextra/components';
|
import { Callout, Steps } from 'nextra/components';
|
||||||
|
|
||||||
import { CallToAction } from '../../../components/call-to-action';
|
import { CallToAction } from '@documenso/ui/components/call-to-action';
|
||||||
|
|
||||||
# Self Hosting
|
# Self Hosting
|
||||||
|
|
||||||
@ -35,8 +35,10 @@ cp .env.example .env
|
|||||||
Open the `.env` file and fill in the following variables:
|
Open the `.env` file and fill in the following variables:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
- NEXTAUTH_URL
|
||||||
- NEXTAUTH_SECRET
|
- NEXTAUTH_SECRET
|
||||||
- NEXT_PUBLIC_WEBAPP_URL
|
- NEXT_PUBLIC_WEBAPP_URL
|
||||||
|
- NEXT_PUBLIC_MARKETING_URL
|
||||||
- NEXT_PRIVATE_DATABASE_URL
|
- NEXT_PRIVATE_DATABASE_URL
|
||||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||||
@ -44,8 +46,8 @@ Open the `.env` file and fill in the following variables:
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Callout type="info">
|
<Callout type="info">
|
||||||
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for the
|
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for both
|
||||||
`NEXT_PUBLIC_WEBAPP_URL` variable!
|
the `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|
||||||
### Install the Dependencies
|
### Install the Dependencies
|
||||||
@ -169,6 +171,7 @@ Run the Docker container with the required environment variables:
|
|||||||
```bash
|
```bash
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
|
-e NEXTAUTH_URL="<your-nextauth-url>"
|
||||||
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
|
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
|
||||||
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
|
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
|
||||||
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
|
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
|
||||||
@ -197,6 +200,7 @@ The environment variables listed above are a subset of those available for confi
|
|||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
| `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. |
|
| `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_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). |
|
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||||
|
|||||||
@ -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.
|
description: A step-by-step guide to setting up and hosting your own Documenso instance.
|
||||||
---
|
---
|
||||||
|
|
||||||
import { CallToAction } from '../../../components/call-to-action';
|
import { CallToAction } from '@documenso/ui/components/call-to-action';
|
||||||
|
|
||||||
# Getting Started with Self-Hosting
|
# Getting Started with Self-Hosting
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,6 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
|
|||||||
- `document.signed`
|
- `document.signed`
|
||||||
- `document.completed`
|
- `document.completed`
|
||||||
- `document.rejected`
|
- `document.rejected`
|
||||||
- `document.cancelled`
|
|
||||||
|
|
||||||
## Create a webhook subscription
|
## Create a webhook subscription
|
||||||
|
|
||||||
@ -38,7 +37,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
|
|||||||
To create a new webhook subscription, you need to provide the following information:
|
To create a new webhook subscription, you need to provide the following information:
|
||||||
|
|
||||||
- Enter the webhook URL that will receive the event payload.
|
- Enter the webhook URL that will receive the event payload.
|
||||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
|
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`.
|
||||||
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
||||||
|
|
||||||

|

|
||||||
@ -529,96 +528,6 @@ Example payload for the `document.rejected` event:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example payload for the `document.rejected` event:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "DOCUMENT_CANCELLED",
|
|
||||||
"payload": {
|
|
||||||
"id": 7,
|
|
||||||
"externalId": null,
|
|
||||||
"userId": 3,
|
|
||||||
"authOptions": null,
|
|
||||||
"formValues": null,
|
|
||||||
"visibility": "EVERYONE",
|
|
||||||
"title": "documenso.pdf",
|
|
||||||
"status": "PENDING",
|
|
||||||
"documentDataId": "cm6exvn93006hi02ru90a265a",
|
|
||||||
"createdAt": "2025-01-27T11:02:14.393Z",
|
|
||||||
"updatedAt": "2025-01-27T11:03:16.387Z",
|
|
||||||
"completedAt": null,
|
|
||||||
"deletedAt": null,
|
|
||||||
"teamId": null,
|
|
||||||
"templateId": null,
|
|
||||||
"source": "DOCUMENT",
|
|
||||||
"documentMeta": {
|
|
||||||
"id": "cm6exvn96006ji02rqvzjvwoy",
|
|
||||||
"subject": "",
|
|
||||||
"message": "",
|
|
||||||
"timezone": "Etc/UTC",
|
|
||||||
"password": null,
|
|
||||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
|
||||||
"redirectUrl": "",
|
|
||||||
"signingOrder": "PARALLEL",
|
|
||||||
"typedSignatureEnabled": true,
|
|
||||||
"language": "en",
|
|
||||||
"distributionMethod": "EMAIL",
|
|
||||||
"emailSettings": {
|
|
||||||
"documentDeleted": true,
|
|
||||||
"documentPending": true,
|
|
||||||
"recipientSigned": true,
|
|
||||||
"recipientRemoved": true,
|
|
||||||
"documentCompleted": true,
|
|
||||||
"ownerDocumentCompleted": true,
|
|
||||||
"recipientSigningRequest": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"documentId": 7,
|
|
||||||
"templateId": null,
|
|
||||||
"email": "mybirihix@mailinator.com",
|
|
||||||
"name": "Zorita Baird",
|
|
||||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
|
||||||
"documentDeletedAt": null,
|
|
||||||
"expired": null,
|
|
||||||
"signedAt": null,
|
|
||||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
|
||||||
"signingOrder": 1,
|
|
||||||
"rejectionReason": null,
|
|
||||||
"role": "SIGNER",
|
|
||||||
"readStatus": "NOT_OPENED",
|
|
||||||
"signingStatus": "NOT_SIGNED",
|
|
||||||
"sendStatus": "SENT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Recipient": [
|
|
||||||
{
|
|
||||||
"id": 7,
|
|
||||||
"documentId": 7,
|
|
||||||
"templateId": null,
|
|
||||||
"email": "signer@documenso.com",
|
|
||||||
"name": "Signer",
|
|
||||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
|
||||||
"documentDeletedAt": null,
|
|
||||||
"expired": null,
|
|
||||||
"signedAt": null,
|
|
||||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
|
||||||
"signingOrder": 1,
|
|
||||||
"rejectionReason": null,
|
|
||||||
"role": "SIGNER",
|
|
||||||
"readStatus": "NOT_OPENED",
|
|
||||||
"signingStatus": "NOT_SIGNED",
|
|
||||||
"sendStatus": "SENT"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"createdAt": "2025-01-27T11:03:27.730Z",
|
|
||||||
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Availability
|
## Availability
|
||||||
|
|
||||||
Webhooks are available to individual users and teams.
|
Webhooks are available to individual users and teams.
|
||||||
|
|||||||
@ -85,13 +85,12 @@ You can also set the recipient's role, which determines their actions and permis
|
|||||||
|
|
||||||
Documenso has 4 roles for recipients with different permissions and actions.
|
Documenso has 4 roles for recipients with different permissions and actions.
|
||||||
|
|
||||||
| Role | Function | Action required | Signature |
|
| Role | Function | Action required | Signature |
|
||||||
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||||
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
|
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||||
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
export interface TransformedData {
|
|
||||||
labels: string[];
|
|
||||||
datasets: Array<{
|
|
||||||
label: string;
|
|
||||||
data: number[];
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addZeroMonth(transformedData: TransformedData): TransformedData {
|
|
||||||
const result = {
|
|
||||||
labels: [...transformedData.labels],
|
|
||||||
datasets: transformedData.datasets.map((dataset) => ({
|
|
||||||
label: dataset.label,
|
|
||||||
data: [...dataset.data],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (result.labels.length === 0) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.datasets.every((dataset) => dataset.data[0] === 0)) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy');
|
|
||||||
if (!firstMonth.isValid) {
|
|
||||||
const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM'];
|
|
||||||
|
|
||||||
for (const format of formats) {
|
|
||||||
firstMonth = DateTime.fromFormat(result.labels[0], format);
|
|
||||||
if (firstMonth.isValid) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!firstMonth.isValid) {
|
|
||||||
console.warn(`Could not parse date: "${result.labels[0]}"`);
|
|
||||||
return transformedData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy');
|
|
||||||
result.labels.unshift(zeroMonth);
|
|
||||||
result.datasets.forEach((dataset) => {
|
|
||||||
dataset.data.unshift(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
return transformedData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +1,7 @@
|
|||||||
import { DocumentStatus } from '@prisma/client';
|
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import { addZeroMonth } from '../add-zero-month';
|
|
||||||
|
|
||||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
@ -37,7 +35,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return addZeroMonth(transformedData);
|
return transformedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
import { addZeroMonth } from '../add-zero-month';
|
|
||||||
|
|
||||||
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Recipient')
|
.selectFrom('Recipient')
|
||||||
@ -36,7 +34,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return addZeroMonth(transformedData);
|
return transformedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetSignerConversionMonthlyResult = Awaited<
|
export type GetSignerConversionMonthlyResult = Awaited<
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import { DateTime } from 'luxon';
|
|||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
import { addZeroMonth } from '../add-zero-month';
|
|
||||||
|
|
||||||
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('User')
|
.selectFrom('User')
|
||||||
@ -34,7 +32,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return addZeroMonth(transformedData);
|
return transformedData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
export type GetUserMonthlyGrowthResult = Awaited<ReturnType<typeof getUserMonthlyGrowth>>;
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { addZeroMonth } from './add-zero-month';
|
|
||||||
|
|
||||||
type MetricKeys = {
|
type MetricKeys = {
|
||||||
stars: number;
|
stars: number;
|
||||||
forks: number;
|
forks: number;
|
||||||
@ -39,77 +37,31 @@ export function transformData({
|
|||||||
data: DataEntry;
|
data: DataEntry;
|
||||||
metric: MetricKey;
|
metric: MetricKey;
|
||||||
}): TransformData {
|
}): TransformData {
|
||||||
try {
|
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||||
if (!data || Object.keys(data).length === 0) {
|
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||||
return {
|
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||||
labels: [],
|
|
||||||
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
||||||
try {
|
});
|
||||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
|
||||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
|
||||||
|
|
||||||
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
|
const labels = sortedEntries.map(([date]) => {
|
||||||
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
|
const [year, month] = date.split('-');
|
||||||
return 0;
|
const dateTime = DateTime.fromObject({
|
||||||
}
|
year: Number(year),
|
||||||
|
month: Number(month),
|
||||||
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sorting entries:', error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
return dateTime.toFormat('MMM yyyy');
|
||||||
|
});
|
||||||
|
|
||||||
const labels = sortedEntries.map(([date]) => {
|
return {
|
||||||
try {
|
labels,
|
||||||
const [year, month] = date.split('-');
|
datasets: [
|
||||||
|
{
|
||||||
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
|
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
||||||
console.warn(`Invalid date format: ${date}`);
|
data: sortedEntries.map(([_, stats]) => stats[metric]),
|
||||||
return date;
|
},
|
||||||
}
|
],
|
||||||
|
};
|
||||||
const dateTime = DateTime.fromObject({
|
|
||||||
year: Number(year),
|
|
||||||
month: Number(month),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dateTime.isValid) {
|
|
||||||
console.warn(`Invalid DateTime object for: ${date}`);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
return dateTime.toFormat('MMM yyyy');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error formatting date:', error, date);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const transformedData = {
|
|
||||||
labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
|
||||||
data: sortedEntries.map(([_, stats]) => {
|
|
||||||
const value = stats[metric];
|
|
||||||
return typeof value === 'number' && !isNaN(value) ? value : 0;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return addZeroMonth(transformedData);
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// To be on the safer side
|
// To be on the safer side
|
||||||
|
|||||||
@ -7,16 +7,17 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules"
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "14.2.6"
|
"next": "14.2.23"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "18.3.5",
|
"@types/react": "^18",
|
||||||
"typescript": "5.6.2"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Exit on error.
|
|
||||||
set -e
|
|
||||||
|
|
||||||
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"
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
# Set Error handling
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
|
||||||
WEB_APP_DIR="$SCRIPT_DIR/.."
|
|
||||||
|
|
||||||
# Store the original directory
|
|
||||||
ORIGINAL_DIR=$(pwd)
|
|
||||||
|
|
||||||
# Set up trap to ensure we return to original directory
|
|
||||||
trap 'cd "$ORIGINAL_DIR"' EXIT
|
|
||||||
|
|
||||||
cd "$WEB_APP_DIR"
|
|
||||||
|
|
||||||
# Define env file paths
|
|
||||||
ENV_LOCAL_FILE="../../.env.local"
|
|
||||||
|
|
||||||
# Function to load environment variable from env files
|
|
||||||
load_env_var() {
|
|
||||||
local var_name=$1
|
|
||||||
local var_value=""
|
|
||||||
|
|
||||||
if [ -f "$ENV_LOCAL_FILE" ]; then
|
|
||||||
var_value=$(grep "^$var_name=" "$ENV_LOCAL_FILE" | cut -d '=' -f2)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove quotes if present
|
|
||||||
var_value=$(echo "$var_value" | sed 's/^"\(.*\)"$/\1/' | sed "s/^'\(.*\)'$/\1/")
|
|
||||||
|
|
||||||
echo "$var_value"
|
|
||||||
}
|
|
||||||
|
|
||||||
NEXT_PUBLIC_FEATURE_BILLING_ENABLED=$(load_env_var "NEXT_PUBLIC_FEATURE_BILLING_ENABLED")
|
|
||||||
|
|
||||||
# Check if NEXT_PUBLIC_FEATURE_BILLING_ENABLED is equal to true
|
|
||||||
if [ "$NEXT_PUBLIC_FEATURE_BILLING_ENABLED" != "true" ]; then
|
|
||||||
echo "[ERROR]: NEXT_PUBLIC_FEATURE_BILLING_ENABLED must be enabled."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. Load NEXT_PRIVATE_STRIPE_API_KEY from env files
|
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY=$(load_env_var "NEXT_PRIVATE_STRIPE_API_KEY")
|
|
||||||
|
|
||||||
# Check if NEXT_PRIVATE_STRIPE_API_KEY exists
|
|
||||||
if [ -z "$NEXT_PRIVATE_STRIPE_API_KEY" ]; then
|
|
||||||
echo "[ERROR]: NEXT_PRIVATE_STRIPE_API_KEY not found in environment files."
|
|
||||||
echo "[ERROR]: Please make sure it's set in $ENV_LOCAL_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. Check if stripe CLI is installed
|
|
||||||
if ! command -v stripe &> /dev/null; then
|
|
||||||
echo "[ERROR]: Stripe CLI is not installed or not in PATH."
|
|
||||||
echo "[ERROR]: Please install the Stripe CLI: https://stripe.com/docs/stripe-cli"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Check if NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET env key exists
|
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=$(load_env_var "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET")
|
|
||||||
|
|
||||||
if [ -z "$NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET" ]; then
|
|
||||||
echo "╔═════════════════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ ! WARNING: NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET MISSING ! ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ Copy the webhook signing secret which will appear in the terminal ║"
|
|
||||||
echo "║ soon into the env file. ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ The webhook secret will start with whsec_... ║"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "╚═════════════════════════════════════════════════════════════════════╝"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[INFO]: Starting Stripe webhook listener..."
|
|
||||||
stripe listen --api-key "$NEXT_PRIVATE_STRIPE_API_KEY" --forward-to http://localhost:3000/api/stripe/webhook
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
.react-router
|
|
||||||
build
|
|
||||||
node_modules
|
|
||||||
README.md
|
|
||||||
9
apps/remix/.gitignore
vendored
9
apps/remix/.gitignore
vendored
@ -1,9 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
/node_modules/
|
|
||||||
|
|
||||||
# React Router
|
|
||||||
/.react-router/
|
|
||||||
/build/
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.*.timestamp*
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
FROM node:20-alpine AS development-dependencies-env
|
|
||||||
COPY . /app
|
|
||||||
WORKDIR /app
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
FROM node:20-alpine AS production-dependencies-env
|
|
||||||
COPY ./package.json package-lock.json /app/
|
|
||||||
WORKDIR /app
|
|
||||||
RUN npm ci --omit=dev
|
|
||||||
|
|
||||||
FROM node:20-alpine AS build-env
|
|
||||||
COPY . /app/
|
|
||||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
WORKDIR /app
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
|
||||||
COPY ./package.json package-lock.json /app/
|
|
||||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
COPY --from=build-env /app/build /app/build
|
|
||||||
WORKDIR /app
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
FROM oven/bun:1 AS dependencies-env
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
FROM dependencies-env AS development-dependencies-env
|
|
||||||
COPY ./package.json bun.lockb /app/
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun i --frozen-lockfile
|
|
||||||
|
|
||||||
FROM dependencies-env AS production-dependencies-env
|
|
||||||
COPY ./package.json bun.lockb /app/
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun i --production
|
|
||||||
|
|
||||||
FROM dependencies-env AS build-env
|
|
||||||
COPY ./package.json bun.lockb /app/
|
|
||||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
WORKDIR /app
|
|
||||||
RUN bun run build
|
|
||||||
|
|
||||||
FROM dependencies-env
|
|
||||||
COPY ./package.json bun.lockb /app/
|
|
||||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
COPY --from=build-env /app/build /app/build
|
|
||||||
WORKDIR /app
|
|
||||||
CMD ["bun", "run", "start"]
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
FROM node:20-alpine AS dependencies-env
|
|
||||||
RUN npm i -g pnpm
|
|
||||||
COPY . /app
|
|
||||||
|
|
||||||
FROM dependencies-env AS development-dependencies-env
|
|
||||||
COPY ./package.json pnpm-lock.yaml /app/
|
|
||||||
WORKDIR /app
|
|
||||||
RUN pnpm i --frozen-lockfile
|
|
||||||
|
|
||||||
FROM dependencies-env AS production-dependencies-env
|
|
||||||
COPY ./package.json pnpm-lock.yaml /app/
|
|
||||||
WORKDIR /app
|
|
||||||
RUN pnpm i --prod --frozen-lockfile
|
|
||||||
|
|
||||||
FROM dependencies-env AS build-env
|
|
||||||
COPY ./package.json pnpm-lock.yaml /app/
|
|
||||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
WORKDIR /app
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
FROM dependencies-env
|
|
||||||
COPY ./package.json pnpm-lock.yaml /app/
|
|
||||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
|
||||||
COPY --from=build-env /app/build /app/build
|
|
||||||
WORKDIR /app
|
|
||||||
CMD ["pnpm", "start"]
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
# Welcome to React Router!
|
|
||||||
|
|
||||||
A modern, production-ready template for building full-stack React applications using React Router.
|
|
||||||
|
|
||||||
[](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.
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
@import '@documenso/ui/styles/theme.css';
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
src: url('/public/fonts/inter-regular.ttf') format('ttf');
|
|
||||||
/* font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap; */
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Caveat';
|
|
||||||
src: url('/public/fonts/caveat.ttf') format('ttf');
|
|
||||||
/* font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap; */
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--font-sans: 'Inter';
|
|
||||||
--font-signature: 'Caveat';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
|
|
||||||
|
|
||||||
export type NextSigner = {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ConfirmationDialogProps = {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: (nextSigner?: NextSigner) => void;
|
|
||||||
hasUninsertedFields: boolean;
|
|
||||||
isSubmitting: boolean;
|
|
||||||
allowDictateNextSigner?: boolean;
|
|
||||||
defaultNextSigner?: NextSigner;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ZNextSignerFormSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
email: z.string().email('Invalid email address'),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
|
||||||
|
|
||||||
export function AssistantConfirmationDialog({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
hasUninsertedFields,
|
|
||||||
isSubmitting,
|
|
||||||
allowDictateNextSigner = false,
|
|
||||||
defaultNextSigner,
|
|
||||||
}: ConfirmationDialogProps) {
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
|
||||||
resolver: zodResolver(ZNextSignerFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onOpenChange = () => {
|
|
||||||
if (isSubmitting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
});
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
// Validate the form and submit it if dictate signer is enabled.
|
|
||||||
if (allowDictateNextSigner) {
|
|
||||||
void form.handleSubmit(onConfirm)();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onConfirm();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent>
|
|
||||||
<Form {...form}>
|
|
||||||
<form>
|
|
||||||
<fieldset disabled={isSubmitting} className="border-none p-0">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Complete Document</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
Are you sure you want to complete the document? This action cannot be undone.
|
|
||||||
Please ensure that you have completed prefilling all relevant fields before
|
|
||||||
proceeding.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
|
||||||
{allowDictateNextSigner && (
|
|
||||||
<div className="my-2">
|
|
||||||
<p className="text-muted-foreground mb-1 text-sm font-semibold">
|
|
||||||
The next recipient to sign this document will be{' '}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 rounded-xl border p-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's name"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="email"
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's email"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant={hasUninsertedFields ? 'destructive' : 'default'}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{hasUninsertedFields ? <Trans>Proceed</Trans> : <Trans>Continue</Trans>}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
|
||||||
|
|
||||||
export type CreateClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
|
||||||
|
|
||||||
export const ClaimCreateDialog = () => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: createClaim, isPending } = trpc.admin.claims.create.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Subscription claim created successfully.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Failed to create subscription claim.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
|
||||||
<Trans>Create claim</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create Subscription Claim</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Fill in the details to create a new subscription claim.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<SubscriptionClaimForm
|
|
||||||
subscriptionClaim={{
|
|
||||||
...generateDefaultSubscriptionClaim(),
|
|
||||||
}}
|
|
||||||
onFormSubmit={createClaim}
|
|
||||||
formSubmitTrigger={
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
<Trans>Create Claim</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type ClaimDeleteDialogProps = {
|
|
||||||
claimId: string;
|
|
||||||
claimName: string;
|
|
||||||
claimLocked: boolean;
|
|
||||||
trigger: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimDeleteDialog = ({
|
|
||||||
claimId,
|
|
||||||
claimName,
|
|
||||||
claimLocked,
|
|
||||||
trigger,
|
|
||||||
}: ClaimDeleteDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteClaim, isPending } = trpc.admin.claims.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Subscription claim deleted successfully.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Failed to delete subscription claim.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
||||||
{trigger}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Delete Subscription Claim</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Are you sure you want to delete the following claim?</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
{claimLocked ? <Trans>This claim is locked and cannot be deleted.</Trans> : claimName}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!claimLocked && (
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isPending}
|
|
||||||
onClick={async () => deleteClaim({ id: claimId })}
|
|
||||||
>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
|
|
||||||
|
|
||||||
export type ClaimUpdateDialogProps = {
|
|
||||||
claim: TFindSubscriptionClaimsResponse['data'][number];
|
|
||||||
trigger: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClaimUpdateDialog = ({ claim, trigger }: ClaimUpdateDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Subscription claim updated successfully.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Failed to update subscription claim.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
||||||
{trigger}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Update Subscription Claim</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Modify the details of the subscription claim.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<SubscriptionClaimForm
|
|
||||||
subscriptionClaim={claim}
|
|
||||||
onFormSubmit={async (data) =>
|
|
||||||
await updateClaim({
|
|
||||||
id: claim.id,
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
formSubmitTrigger={
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isPending}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={isPending}>
|
|
||||||
<Trans>Update Claim</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,402 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { ExternalLinkIcon } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
|
||||||
import { parseMessageDescriptorMacro } from '@documenso/lib/utils/i18n';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZCreateOrganisationFormSchema = ZCreateOrganisationRequestSchema.pick({
|
|
||||||
name: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
|
|
||||||
|
|
||||||
export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCreateDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
|
||||||
|
|
||||||
const [step, setStep] = useState<'billing' | 'create'>(
|
|
||||||
IS_BILLING_ENABLED() ? 'billing' : 'create',
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZCreateOrganisationFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
|
|
||||||
|
|
||||||
const { data: plansData } = trpc.billing.plans.get.useQuery();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
|
|
||||||
try {
|
|
||||||
const response = await createOrganisation({
|
|
||||||
name,
|
|
||||||
priceId: selectedPriceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.paymentRequired) {
|
|
||||||
window.open(response.checkoutUrl, '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Your organisation has been created.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to create a organisation. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionSearchParam === 'add-organisation') {
|
|
||||||
setOpen(true);
|
|
||||||
updateSearchParams({ action: null });
|
|
||||||
}
|
|
||||||
}, [actionSearchParam, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset();
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
|
||||||
<Trans>Create organisation</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
{match(step)
|
|
||||||
.with('billing', () => (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Select a plan</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select a plan to continue</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<fieldset aria-label="Plan select">
|
|
||||||
{plansData ? (
|
|
||||||
<BillingPlanForm
|
|
||||||
value={selectedPriceId}
|
|
||||||
onChange={setSelectedPriceId}
|
|
||||||
plans={plansData.plans}
|
|
||||||
canCreateFreeOrganisation={plansData.canCreateFreeOrganisation}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SpinnerBox className="py-32" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" onClick={() => setStep('create')}>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with('create', () => (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create organisation</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Create an organisation to collaborate with teams</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
{IS_BILLING_ENABLED() ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setStep('billing')}
|
|
||||||
>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
data-testid="dialog-create-organisation-button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
{selectedPriceId ? <Trans>Checkout</Trans> : <Trans>Create</Trans>}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
|
|
||||||
.exhaustive()}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type BillingPlanFormProps = {
|
|
||||||
value: string;
|
|
||||||
onChange: (priceId: string) => void;
|
|
||||||
plans: InternalClaimPlans;
|
|
||||||
canCreateFreeOrganisation: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BillingPlanForm = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
plans,
|
|
||||||
canCreateFreeOrganisation,
|
|
||||||
}: BillingPlanFormProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const [billingPeriod, setBillingPeriod] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
|
|
||||||
|
|
||||||
const dynamicPlans = useMemo(() => {
|
|
||||||
return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.PRO, INTERNAL_CLAIM_ID.PLATFORM].map(
|
|
||||||
(planId) => {
|
|
||||||
const plan = plans[planId];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: planId,
|
|
||||||
name: plan.name,
|
|
||||||
description: parseMessageDescriptorMacro(t, plan.description),
|
|
||||||
monthlyPrice: plan.monthlyPrice,
|
|
||||||
yearlyPrice: plan.yearlyPrice,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [plans]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (value === '' && !canCreateFreeOrganisation) {
|
|
||||||
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
|
|
||||||
const plan = dynamicPlans.find((plan) => plan[billingPeriod]?.id === value);
|
|
||||||
|
|
||||||
setBillingPeriod(billingPeriod);
|
|
||||||
|
|
||||||
onChange(plan?.[billingPeriod]?.id ?? Object.keys(plans)[0]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Tabs
|
|
||||||
className="flex w-full items-center justify-center"
|
|
||||||
defaultValue="monthlyPrice"
|
|
||||||
value={billingPeriod}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
onValueChange={(value) => onBillingPeriodChange(value as 'monthlyPrice' | 'yearlyPrice')}
|
|
||||||
>
|
|
||||||
<TabsList className="flex w-full justify-center">
|
|
||||||
<TabsTrigger className="w-full" value="monthlyPrice">
|
|
||||||
<Trans>Monthly</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger className="w-full" value="yearlyPrice">
|
|
||||||
<Trans>Yearly</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 text-sm">
|
|
||||||
<button
|
|
||||||
onClick={() => onChange('')}
|
|
||||||
className={cn(
|
|
||||||
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
|
|
||||||
{
|
|
||||||
'ring-primary/10 border-primary ring-2 ring-offset-1': '' === value,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
disabled={!canCreateFreeOrganisation}
|
|
||||||
>
|
|
||||||
<div className="w-full text-left">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-medium">
|
|
||||||
<Trans>Free</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Badge size="small" variant="neutral" className="ml-1.5">
|
|
||||||
{canCreateFreeOrganisation ? (
|
|
||||||
<Trans>1 Free organisations left</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>0 Free organisations left</Trans>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
<Trans>5 documents a month</Trans>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{dynamicPlans.map((plan) => (
|
|
||||||
<button
|
|
||||||
key={plan[billingPeriod]?.id}
|
|
||||||
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
|
|
||||||
className={cn(
|
|
||||||
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
|
|
||||||
{
|
|
||||||
'ring-primary/10 border-primary ring-2 ring-offset-1':
|
|
||||||
plan[billingPeriod]?.id === value,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="w-full text-left">
|
|
||||||
<p className="font-medium">{plan.name}</p>
|
|
||||||
<p className="text-muted-foreground">{plan.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<p>{plan[billingPeriod]?.friendlyPrice}</p>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{billingPeriod === 'monthlyPrice' ? (
|
|
||||||
<Trans>per month</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>per year</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="https://documen.so/enterprise-cta"
|
|
||||||
target="_blank"
|
|
||||||
className="bg-muted/30 flex items-center space-x-2 rounded-md border p-4"
|
|
||||||
>
|
|
||||||
<div className="flex-1 font-normal">
|
|
||||||
<p className="text-muted-foreground font-medium">
|
|
||||||
<Trans>Enterprise</Trans>
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground flex flex-row items-center gap-1">
|
|
||||||
<Trans>Contact sales here</Trans>
|
|
||||||
<ExternalLinkIcon className="h-4 w-4" />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<Link
|
|
||||||
to="https://documenso.com/pricing"
|
|
||||||
className="text-primary hover:text-primary/80 flex items-center justify-center gap-1 text-sm hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Trans>Compare all plans and features in detail</Trans>
|
|
||||||
<ExternalLinkIcon className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,163 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationDeleteDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const deleteMessage = _(msg`delete ${organisation.name}`);
|
|
||||||
|
|
||||||
const ZDeleteOrganisationFormSchema = z.object({
|
|
||||||
organisationName: z.literal(deleteMessage, {
|
|
||||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZDeleteOrganisationFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
organisationName: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: deleteOrganisation } = trpc.organisation.delete.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await deleteOrganisation({ organisationId: organisation.id });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Your organisation has been successfully deleted.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await navigate('/settings/organisations');
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to delete this organisation. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure you wish to delete this organisation?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
You are about to delete <span className="font-semibold">{organisation.name}</span>.
|
|
||||||
All data related to this organisation such as teams, documents, and all other
|
|
||||||
resources will be deleted. This action is irreversible.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="organisationName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>
|
|
||||||
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
|
|
||||||
</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import {
|
|
||||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
|
||||||
ORGANISATION_MEMBER_ROLE_MAP,
|
|
||||||
} from '@documenso/lib/constants/organisations';
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateOrganisationGroupRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-group.types';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationGroupCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema.pick({
|
|
||||||
name: true,
|
|
||||||
memberIds: true,
|
|
||||||
organisationRole: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateOrganisationGroupFormSchema = z.infer<typeof ZCreateOrganisationGroupFormSchema>;
|
|
||||||
|
|
||||||
export const OrganisationGroupCreateDialog = ({
|
|
||||||
trigger,
|
|
||||||
...props
|
|
||||||
}: OrganisationGroupCreateDialogProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZCreateOrganisationGroupFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
organisationRole: OrganisationMemberRole.MEMBER,
|
|
||||||
memberIds: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation();
|
|
||||||
|
|
||||||
const { data: membersFindResult, isLoading: isLoadingMembers } =
|
|
||||||
trpc.organisation.member.find.useQuery({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const members = membersFindResult?.data ?? [];
|
|
||||||
|
|
||||||
const onFormSubmit = async ({
|
|
||||||
name,
|
|
||||||
organisationRole,
|
|
||||||
memberIds,
|
|
||||||
}: TCreateOrganisationGroupFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createOrganisationGroup({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
name,
|
|
||||||
organisationRole,
|
|
||||||
memberIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Group has been created.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to create a group. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset();
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
|
||||||
<Trans>Create group</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create group</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Organise your members into groups which can be assigned to teams</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Group Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="organisationRole"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
|
|
||||||
organisation.currentOrganisationRole
|
|
||||||
].map((role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{t(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="memberIds"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Members</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={members.map((member) => ({
|
|
||||||
label: member.name,
|
|
||||||
value: member.id,
|
|
||||||
}))}
|
|
||||||
loading={isLoadingMembers}
|
|
||||||
selectedValues={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
className="bg-background w-full"
|
|
||||||
emptySelectionPlaceholder={t`Select members`}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Select the members to add to this group</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
data-testid="dialog-create-organisation-button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Create</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationGroupDeleteDialogProps = {
|
|
||||||
organisationGroupId: string;
|
|
||||||
organisationGroupName: string;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationGroupDeleteDialog = ({
|
|
||||||
trigger,
|
|
||||||
organisationGroupId,
|
|
||||||
organisationGroupName,
|
|
||||||
}: OrganisationGroupDeleteDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteGroup, isPending: isDeleting } =
|
|
||||||
trpc.organisation.group.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have successfully removed this group from the organisation.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Delete organisation group</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to remove the following group from{' '}
|
|
||||||
<span className="font-semibold">{organisation.name}</span>.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
{organisationGroupName}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<fieldset disabled={isDeleting}>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDeleting}
|
|
||||||
onClick={async () =>
|
|
||||||
deleteGroup({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
groupId: organisationGroupId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { OrganisationMemberRole } from '@prisma/client';
|
|
||||||
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
|
||||||
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';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationLeaveDialogProps = {
|
|
||||||
organisationId: string;
|
|
||||||
organisationName: string;
|
|
||||||
organisationAvatarImageId?: string | null;
|
|
||||||
role: OrganisationMemberRole;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationLeaveDialog = ({
|
|
||||||
trigger,
|
|
||||||
organisationId,
|
|
||||||
organisationName,
|
|
||||||
organisationAvatarImageId,
|
|
||||||
role,
|
|
||||||
}: OrganisationLeaveDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } =
|
|
||||||
trpc.organisation.leave.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`You have successfully left this organisation.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLeavingOrganisation && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="destructive">
|
|
||||||
<Trans>Leave organisation</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>You are about to leave the following organisation.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral" padding="tight">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarSrc={formatAvatarUrl(organisationAvatarImageId)}
|
|
||||||
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={organisationName}
|
|
||||||
secondaryText={t(ORGANISATION_MEMBER_ROLE_MAP[role])}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<fieldset disabled={isLeavingOrganisation}>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isLeavingOrganisation}
|
|
||||||
onClick={async () => leaveOrganisation({ organisationId })}
|
|
||||||
>
|
|
||||||
<Trans>Leave</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationMemberDeleteDialogProps = {
|
|
||||||
organisationMemberId: string;
|
|
||||||
organisationMemberName: string;
|
|
||||||
organisationMemberEmail: string;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationMemberDeleteDialog = ({
|
|
||||||
trigger,
|
|
||||||
organisationMemberId,
|
|
||||||
organisationMemberName,
|
|
||||||
organisationMemberEmail,
|
|
||||||
}: OrganisationMemberDeleteDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteOrganisationMembers, isPending: isDeletingOrganisationMember } =
|
|
||||||
trpc.organisation.member.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have successfully removed this user from the organisation.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to remove this user. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeletingOrganisationMember && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Delete organisation member</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to remove the following user from{' '}
|
|
||||||
<span className="font-semibold">{organisation.name}</span>.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Alert variant="neutral" padding="tight">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
|
|
||||||
secondaryText={organisationMemberEmail}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<fieldset disabled={isDeletingOrganisationMember}>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDeletingOrganisationMember}
|
|
||||||
onClick={async () =>
|
|
||||||
deleteOrganisationMembers({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
organisationMemberId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
|
||||||
ORGANISATION_MEMBER_ROLE_MAP,
|
|
||||||
} from '@documenso/lib/constants/organisations';
|
|
||||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationMemberUpdateDialogProps = {
|
|
||||||
currentUserOrganisationRole: OrganisationMemberRole;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
organisationId: string;
|
|
||||||
organisationMemberId: string;
|
|
||||||
organisationMemberName: string;
|
|
||||||
organisationMemberRole: OrganisationMemberRole;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZUpdateOrganisationMemberFormSchema = z.object({
|
|
||||||
role: z.nativeEnum(OrganisationMemberRole),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
|
|
||||||
|
|
||||||
export const OrganisationMemberUpdateDialog = ({
|
|
||||||
currentUserOrganisationRole,
|
|
||||||
trigger,
|
|
||||||
organisationId,
|
|
||||||
organisationMemberId,
|
|
||||||
organisationMemberName,
|
|
||||||
organisationMemberRole,
|
|
||||||
...props
|
|
||||||
}: OrganisationMemberUpdateDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<ZUpdateOrganisationMemberSchema>({
|
|
||||||
resolver: zodResolver(ZUpdateOrganisationMemberFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
role: organisationMemberRole,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateOrganisationMember } = trpc.organisation.member.update.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
|
|
||||||
try {
|
|
||||||
await updateOrganisationMember({
|
|
||||||
organisationId,
|
|
||||||
organisationMemberId,
|
|
||||||
data: {
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have updated ${organisationMemberName}.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to update this organisation member. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
|
|
||||||
if (
|
|
||||||
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
|
|
||||||
) {
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Update organisation member</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Update organisation member</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are currently updating{' '}
|
|
||||||
<span className="font-bold">{organisationMemberName}.</span>
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="role"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent className="w-full" position="popper">
|
|
||||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole].map(
|
|
||||||
(role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Update</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,314 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
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 type { z } from 'zod';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
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 { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TeamCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
onCreated?: () => Promise<void>;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZCreateTeamFormSchema = ZCreateTeamRequestSchema.pick({
|
|
||||||
teamName: true,
|
|
||||||
teamUrl: true,
|
|
||||||
inheritMembers: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
|
||||||
|
|
||||||
export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { refreshSession } = useSession();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
|
|
||||||
organisationReference: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZCreateTeamFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
teamName: '',
|
|
||||||
teamUrl: '',
|
|
||||||
inheritMembers: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createTeam({
|
|
||||||
organisationId: organisation.id,
|
|
||||||
teamName,
|
|
||||||
teamUrl,
|
|
||||||
inheritMembers,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
await onCreated?.();
|
|
||||||
await refreshSession();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Your team has been created.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
form.setError('teamUrl', {
|
|
||||||
type: 'manual',
|
|
||||||
message: _(msg`This URL is already in use.`),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to create a team. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapTextToUrl = (text: string) => {
|
|
||||||
return text.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
};
|
|
||||||
|
|
||||||
const dialogState = useMemo(() => {
|
|
||||||
if (!fullOrganisation) {
|
|
||||||
return 'loading';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullOrganisation.organisationClaim.teamCount === 0) {
|
|
||||||
return 'form';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fullOrganisation.organisationClaim.teamCount <= fullOrganisation.teams.length) {
|
|
||||||
return 'alert';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'form';
|
|
||||||
}, [fullOrganisation]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionSearchParam === 'add-team') {
|
|
||||||
setOpen(true);
|
|
||||||
updateSearchParams({ action: null });
|
|
||||||
}
|
|
||||||
}, [actionSearchParam, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset();
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button className="flex-shrink-0" variant="secondary">
|
|
||||||
<Trans>Create team</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Create team</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Create a team to collaborate with your team members.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{dialogState === 'loading' && <SpinnerBox className="py-32" />}
|
|
||||||
|
|
||||||
{dialogState === 'alert' && (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
|
||||||
variant="neutral"
|
|
||||||
>
|
|
||||||
<AlertDescription className="mr-2">
|
|
||||||
<Trans>
|
|
||||||
You have reached the maximum number of teams for your plan. Please contact sales
|
|
||||||
at <a href="mailto:support@documenso.com">support@documenso.com</a> if you would
|
|
||||||
like to adjust your plan.
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{dialogState === 'form' && (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full flex-col space-y-4"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="teamName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="bg-background"
|
|
||||||
{...field}
|
|
||||||
onChange={(event) => {
|
|
||||||
const oldGeneratedUrl = mapTextToUrl(field.value);
|
|
||||||
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
|
||||||
|
|
||||||
const urlField = form.getValues('teamUrl');
|
|
||||||
if (urlField === oldGeneratedUrl) {
|
|
||||||
form.setValue('teamUrl', newGeneratedUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
field.onChange(event);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="teamUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team URL</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
{!form.formState.errors.teamUrl && (
|
|
||||||
<span className="text-foreground/50 text-xs font-normal">
|
|
||||||
{field.value ? (
|
|
||||||
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
|
|
||||||
) : (
|
|
||||||
<Trans>A unique URL to identify your team</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="inheritMembers"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center space-x-2">
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Checkbox
|
|
||||||
id="inherit-members"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label
|
|
||||||
className="text-muted-foreground ml-2 text-sm"
|
|
||||||
htmlFor="inherit-members"
|
|
||||||
>
|
|
||||||
<Trans>Allow all organisation members to access this team</Trans>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
data-testid="dialog-create-team-button"
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Create Team</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,304 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TeamGroupCreateDialogProps = Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZAddTeamMembersFormSchema = z.object({
|
|
||||||
groups: z.array(
|
|
||||||
z.object({
|
|
||||||
organisationGroupId: z.string(),
|
|
||||||
teamRole: z.nativeEnum(TeamMemberRole),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
|
||||||
|
|
||||||
export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT');
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<TAddTeamMembersFormSchema>({
|
|
||||||
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
groups: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation();
|
|
||||||
|
|
||||||
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
|
|
||||||
organisationId: team.organisationId,
|
|
||||||
perPage: 100, // Won't really work if they somehow have more than 100 groups.
|
|
||||||
types: [OrganisationGroupType.CUSTOM],
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamGroupQuery = trpc.team.group.find.useQuery({
|
|
||||||
teamId: team.id,
|
|
||||||
perPage: 100, // Won't really work if they somehow have more than 100 groups.
|
|
||||||
});
|
|
||||||
|
|
||||||
const avaliableOrganisationGroups = useMemo(() => {
|
|
||||||
const organisationGroups = organisationGroupQuery.data?.data ?? [];
|
|
||||||
const teamGroups = teamGroupQuery.data?.data ?? [];
|
|
||||||
|
|
||||||
return organisationGroups.filter(
|
|
||||||
(group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id),
|
|
||||||
);
|
|
||||||
}, [organisationGroupQuery, teamGroupQuery]);
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createTeamGroups({
|
|
||||||
teamId: team.id,
|
|
||||||
groups,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Team members have been added.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
setStep('SELECT');
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
|
||||||
// Since it would be annoying to redo the whole process.
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
|
||||||
<Trans>Add groups</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent hideClose={true} position="center">
|
|
||||||
{match(step)
|
|
||||||
.with('SELECT', () => (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add members</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select members or groups of members to add to the team.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
))
|
|
||||||
.with('ROLES', () => (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add group roles</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Configure the team roles for each group</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting}>
|
|
||||||
{step === 'SELECT' && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="groups"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Groups</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={avaliableOrganisationGroups.map((group) => ({
|
|
||||||
label: group.name ?? group.organisationRole,
|
|
||||||
value: group.id,
|
|
||||||
}))}
|
|
||||||
loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading}
|
|
||||||
selectedValues={field.value.map(
|
|
||||||
({ organisationGroupId }) => organisationGroupId,
|
|
||||||
)}
|
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(
|
|
||||||
value.map((organisationGroupId) => ({
|
|
||||||
organisationGroupId,
|
|
||||||
teamRole:
|
|
||||||
field.value.find(
|
|
||||||
(value) => value.organisationGroupId === organisationGroupId,
|
|
||||||
)?.teamRole || TeamMemberRole.MEMBER,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="bg-background w-full"
|
|
||||||
emptySelectionPlaceholder={t`Select groups`}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Select groups to add to this team</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={form.getValues('groups').length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
setStep('ROLES');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'ROLES' && (
|
|
||||||
<>
|
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
|
||||||
{form.getValues('groups').map((group, index) => (
|
|
||||||
<div className="flex w-full flex-row space-x-4" key={index}>
|
|
||||||
<div className="w-full space-y-2">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Group</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
readOnly
|
|
||||||
className="bg-background"
|
|
||||||
value={
|
|
||||||
avaliableOrganisationGroups.find(
|
|
||||||
({ id }) => id === group.organisationGroupId,
|
|
||||||
)?.name || t`Untitled Group`
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`groups.${index}.teamRole`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
|
|
||||||
(role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Create Groups</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { TeamMemberRole } from '@prisma/client';
|
|
||||||
|
|
||||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TeamGroupDeleteDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
teamGroupId: string;
|
|
||||||
teamGroupName: string;
|
|
||||||
teamGroupRole: TeamMemberRole;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamGroupDeleteDialog = ({
|
|
||||||
trigger,
|
|
||||||
teamGroupId,
|
|
||||||
teamGroupName,
|
|
||||||
teamGroupRole,
|
|
||||||
}: TeamGroupDeleteDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.team.group.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have successfully removed this group from the team.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Delete team group</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Are you sure?</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Trans>
|
|
||||||
You are about to remove the following group from{' '}
|
|
||||||
<span className="font-semibold">{team.name}</span>.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
|
|
||||||
<>
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
{teamGroupName}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<fieldset disabled={isDeleting}>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDeleting}
|
|
||||||
onClick={async () =>
|
|
||||||
deleteGroup({
|
|
||||||
teamId: team.id,
|
|
||||||
teamGroupId: teamGroupId,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
<Trans>You cannot delete a group which has a higher role than you.</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Close</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
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 {
|
|
||||||
EXTENDED_TEAM_MEMBER_ROLE_MAP,
|
|
||||||
TEAM_MEMBER_ROLE_HIERARCHY,
|
|
||||||
} from '@documenso/lib/constants/teams';
|
|
||||||
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TeamGroupUpdateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
teamGroupId: string;
|
|
||||||
teamGroupName: string;
|
|
||||||
teamGroupRole: TeamMemberRole;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZUpdateTeamGroupFormSchema = z.object({
|
|
||||||
role: z.nativeEnum(TeamMemberRole),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ZUpdateTeamGroupSchema = z.infer<typeof ZUpdateTeamGroupFormSchema>;
|
|
||||||
|
|
||||||
export const TeamGroupUpdateDialog = ({
|
|
||||||
trigger,
|
|
||||||
teamGroupId,
|
|
||||||
teamGroupName,
|
|
||||||
teamGroupRole,
|
|
||||||
...props
|
|
||||||
}: TeamGroupUpdateDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<ZUpdateTeamGroupSchema>({
|
|
||||||
resolver: zodResolver(ZUpdateTeamGroupFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
role: teamGroupRole,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateTeamGroup } = trpc.team.group.update.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ role }: ZUpdateTeamGroupSchema) => {
|
|
||||||
try {
|
|
||||||
await updateTeamGroup({
|
|
||||||
id: teamGroupId,
|
|
||||||
data: {
|
|
||||||
teamRole: role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`You have updated the team group.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to update this team member. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="secondary">
|
|
||||||
<Trans>Update team group</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Update team group</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
You are currently updating the <span className="font-bold">{teamGroupName}</span> team
|
|
||||||
group.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="role"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent className="w-full" position="popper">
|
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map((role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{_(EXTENDED_TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Update</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<AlertDescription className="text-center font-semibold">
|
|
||||||
<Trans>You cannot modify a group which has a higher role than you.</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Close</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,304 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans, useLingui } 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 { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export type TeamMemberCreateDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
const ZAddTeamMembersFormSchema = z.object({
|
|
||||||
members: z.array(
|
|
||||||
z.object({
|
|
||||||
organisationMemberId: z.string(),
|
|
||||||
teamRole: z.nativeEnum(TeamMemberRole),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
|
||||||
|
|
||||||
export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
|
|
||||||
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<TAddTeamMembersFormSchema>({
|
|
||||||
resolver: zodResolver(ZAddTeamMembersFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
members: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation();
|
|
||||||
|
|
||||||
const organisationMemberQuery = trpc.organisation.member.find.useQuery({
|
|
||||||
organisationId: team.organisationId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamMemberQuery = trpc.team.member.find.useQuery({
|
|
||||||
teamId: team.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const avaliableOrganisationMembers = useMemo(() => {
|
|
||||||
const organisationMembers = organisationMemberQuery.data?.data ?? [];
|
|
||||||
const teamMembers = teamMemberQuery.data?.data ?? [];
|
|
||||||
|
|
||||||
return organisationMembers.filter(
|
|
||||||
(member) => !teamMembers.some((teamMember) => teamMember.id === member.id),
|
|
||||||
);
|
|
||||||
}, [organisationMemberQuery, teamMemberQuery]);
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createTeamMembers({
|
|
||||||
teamId: team.id,
|
|
||||||
organisationMembers: members,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t`Success`,
|
|
||||||
description: t`Team members have been added.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: t`An unknown error occurred`,
|
|
||||||
description: t`We encountered an unknown error while attempting to add team members. Please try again later.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
setStep('SELECT');
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
|
|
||||||
// Since it would be annoying to redo the whole process.
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
<Button variant="secondary" onClick={() => setOpen(true)}>
|
|
||||||
<Trans>Add members</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent hideClose={true} position="center">
|
|
||||||
{match(step)
|
|
||||||
.with('SELECT', () => (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add members</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Select members or groups of members to add to the team.</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
))
|
|
||||||
.with('MEMBERS', () => (
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Add members roles</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>Configure the team roles for each member</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting}>
|
|
||||||
{step === 'SELECT' && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="members"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Members</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={avaliableOrganisationMembers.map((member) => ({
|
|
||||||
label: member.name,
|
|
||||||
value: member.id,
|
|
||||||
}))}
|
|
||||||
loading={organisationMemberQuery.isLoading}
|
|
||||||
selectedValues={field.value.map(
|
|
||||||
(member) => member.organisationMemberId,
|
|
||||||
)}
|
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(
|
|
||||||
value.map((organisationMemberId) => ({
|
|
||||||
organisationMemberId,
|
|
||||||
teamRole:
|
|
||||||
field.value.find(
|
|
||||||
(member) =>
|
|
||||||
member.organisationMemberId === organisationMemberId,
|
|
||||||
)?.teamRole || TeamMemberRole.MEMBER,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="bg-background w-full"
|
|
||||||
emptySelectionPlaceholder={t`Select members`}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Select members to add to this team</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={form.getValues('members').length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
setStep('MEMBERS');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'MEMBERS' && (
|
|
||||||
<>
|
|
||||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
|
||||||
{form.getValues('members').map((member, index) => (
|
|
||||||
<div className="flex w-full flex-row space-x-4" key={index}>
|
|
||||||
<div className="w-full space-y-2">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Member</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
readOnly
|
|
||||||
className="bg-background"
|
|
||||||
value={
|
|
||||||
organisationMemberQuery.data?.data.find(
|
|
||||||
({ id }) => id === member.organisationMemberId,
|
|
||||||
)?.name || ''
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`members.${index}.teamRole`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && (
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Team Role</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
|
|
||||||
(role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setStep('SELECT')}>
|
|
||||||
<Trans>Back</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Add Members</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,274 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { File as FileIcon, Upload, X } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
const ZBulkSendFormSchema = z.object({
|
|
||||||
file: z.instanceof(File),
|
|
||||||
sendImmediately: z.boolean().default(false),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
|
||||||
|
|
||||||
export type TemplateBulkSendDialogProps = {
|
|
||||||
templateId: number;
|
|
||||||
recipients: Array<{ email: string; name?: string | null }>;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TemplateBulkSendDialog = ({
|
|
||||||
templateId,
|
|
||||||
recipients,
|
|
||||||
trigger,
|
|
||||||
onSuccess,
|
|
||||||
}: TemplateBulkSendDialogProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const form = useForm<TBulkSendFormSchema>({
|
|
||||||
resolver: zodResolver(ZBulkSendFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
sendImmediately: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
|
|
||||||
|
|
||||||
const onDownloadTemplate = () => {
|
|
||||||
const headers = recipients.flatMap((_, index) => [
|
|
||||||
`recipient_${index + 1}_email`,
|
|
||||||
`recipient_${index + 1}_name`,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
|
|
||||||
|
|
||||||
const csv = [headers.join(','), exampleRow.join(',')].join('\n');
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const a = Object.assign(document.createElement('a'), {
|
|
||||||
href: url,
|
|
||||||
download: 'template.csv',
|
|
||||||
});
|
|
||||||
|
|
||||||
a.click();
|
|
||||||
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (values: TBulkSendFormSchema) => {
|
|
||||||
try {
|
|
||||||
const csv = await values.file.text();
|
|
||||||
|
|
||||||
await uploadBulkSend({
|
|
||||||
templateId,
|
|
||||||
teamId: team?.id,
|
|
||||||
csv: csv,
|
|
||||||
sendImmediately: values.sendImmediately,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(
|
|
||||||
msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset();
|
|
||||||
onSuccess?.();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'Failed to upload CSV. Please check the file format and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Bulk Send via CSV</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Bulk Send Template via CSV</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
Upload a CSV file to create multiple documents from this template. Each row represents
|
|
||||||
one document with its recipient details.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
|
||||||
<div className="bg-muted/70 rounded-lg border p-4">
|
|
||||||
<h3 className="text-sm font-medium">
|
|
||||||
<Trans>CSV Structure</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
<Trans>
|
|
||||||
For each recipient, provide their email (required) and name (optional) in separate
|
|
||||||
columns. Download the template CSV below for the correct format.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="mt-4 text-sm">
|
|
||||||
<Trans>Current recipients:</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
|
|
||||||
{recipients.map((recipient, index) => (
|
|
||||||
<li key={index}>
|
|
||||||
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-y-2">
|
|
||||||
<Button onClick={onDownloadTemplate} variant="outline" type="button">
|
|
||||||
<Trans>Download Template CSV</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
<Trans>Pre-formatted CSV template with example data.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="file"
|
|
||||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormControl>
|
|
||||||
{!value ? (
|
|
||||||
<Button asChild variant="outline" className="w-full">
|
|
||||||
<label className="cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
onChange(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
/>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Upload CSV</Trans>
|
|
||||||
</label>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-10 items-center rounded-md border px-3">
|
|
||||||
<div className="flex flex-1 items-center gap-2">
|
|
||||||
<FileIcon className="text-muted-foreground h-4 w-4" />
|
|
||||||
<span className="flex-1 truncate text-sm">{value.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
className="text-destructive hover:text-destructive p-0 text-xs"
|
|
||||||
onClick={() => onChange(null)}
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">
|
|
||||||
<Trans>Remove</Trans>
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{error && <p className="text-destructive text-sm">{error.message}</p>}
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
<Trans>
|
|
||||||
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
|
|
||||||
template defaults.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sendImmediately"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center space-x-2">
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Checkbox
|
|
||||||
id="send-immediately"
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label
|
|
||||||
htmlFor="send-immediately"
|
|
||||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
|
||||||
>
|
|
||||||
<Trans>Send documents to recipients immediately</Trans>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Upload and Process</Trans>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
|
||||||
|
|
||||||
export type EmbedAuthenticationRequiredProps = {
|
|
||||||
email?: string;
|
|
||||||
returnTo: string;
|
|
||||||
isGoogleSSOEnabled?: boolean;
|
|
||||||
isOIDCSSOEnabled?: boolean;
|
|
||||||
oidcProviderLabel?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmbedAuthenticationRequired = ({
|
|
||||||
email,
|
|
||||||
returnTo,
|
|
||||||
// isGoogleSSOEnabled,
|
|
||||||
// isOIDCSSOEnabled,
|
|
||||||
// oidcProviderLabel,
|
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-[100dvh] w-full items-center justify-center">
|
|
||||||
<div className="flex w-full max-w-md flex-col">
|
|
||||||
<BrandingLogo className="h-8" />
|
|
||||||
|
|
||||||
<Alert className="mt-8" variant="warning">
|
|
||||||
<AlertDescription>
|
|
||||||
<Trans>
|
|
||||||
To view this document you need to be signed into your account, please sign in to
|
|
||||||
continue.
|
|
||||||
</Trans>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<SignInForm
|
|
||||||
// Embed currently not supported.
|
|
||||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
|
||||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
|
||||||
// oidcProviderLabel={oidcProviderLabel}
|
|
||||||
className="mt-4"
|
|
||||||
initialEmail={email}
|
|
||||||
returnTo={returnTo}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { XCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
export const EmbedDocumentRejected = () => {
|
|
||||||
return (
|
|
||||||
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<XCircle className="text-destructive h-10 w-10" />
|
|
||||||
|
|
||||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
|
||||||
<Trans>Document Rejected</Trans>
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-destructive mt-4 flex items-center text-center text-sm">
|
|
||||||
<Trans>You have rejected this document</Trans>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
|
||||||
<Trans>
|
|
||||||
The document owner has been notified of your decision. They may contact you with further
|
|
||||||
instructions if necessary.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
|
||||||
<Trans>No further action is required from you at this time.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,492 +0,0 @@
|
|||||||
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
|
||||||
import {
|
|
||||||
type DocumentData,
|
|
||||||
type Field,
|
|
||||||
FieldType,
|
|
||||||
RecipientRole,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
|
||||||
import { injectCss } from '~/utils/css-vars';
|
|
||||||
|
|
||||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
|
||||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
|
||||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
|
||||||
import { DocumentReadOnlyFields } from '../general/document/document-read-only-fields';
|
|
||||||
import { EmbedClientLoading } from './embed-client-loading';
|
|
||||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
|
||||||
import { EmbedDocumentFields } from './embed-document-fields';
|
|
||||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
|
||||||
|
|
||||||
export type EmbedSignDocumentClientPageProps = {
|
|
||||||
token: string;
|
|
||||||
documentId: number;
|
|
||||||
documentData: DocumentData;
|
|
||||||
recipient: RecipientWithFields;
|
|
||||||
fields: Field[];
|
|
||||||
completedFields: DocumentField[];
|
|
||||||
metadata?: DocumentMeta | TemplateMeta | null;
|
|
||||||
isCompleted?: boolean;
|
|
||||||
hidePoweredBy?: boolean;
|
|
||||||
allowWhitelabelling?: boolean;
|
|
||||||
allRecipients?: RecipientWithFields[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmbedSignDocumentClientPage = ({
|
|
||||||
token,
|
|
||||||
documentId,
|
|
||||||
documentData,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
completedFields,
|
|
||||||
metadata,
|
|
||||||
isCompleted,
|
|
||||||
hidePoweredBy = false,
|
|
||||||
allowWhitelabelling = false,
|
|
||||||
allRecipients = [],
|
|
||||||
}: EmbedSignDocumentClientPageProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { fullName, email, signature, setFullName, setSignature } =
|
|
||||||
useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
|
||||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
|
||||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
|
||||||
const [hasRejectedDocument, setHasRejectedDocument] = useState(
|
|
||||||
recipient.signingStatus === SigningStatus.REJECTED,
|
|
||||||
);
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
|
||||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
|
||||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
|
||||||
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
|
||||||
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 && 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(fieldsRequiringValidation);
|
|
||||||
|
|
||||||
setShowPendingFieldTooltip(true);
|
|
||||||
setIsExpanded(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCompleteClick = async () => {
|
|
||||||
try {
|
|
||||||
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
setShowPendingFieldTooltip(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await completeDocumentWithToken({
|
|
||||||
documentId,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (window.parent) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-completed',
|
|
||||||
data: {
|
|
||||||
token,
|
|
||||||
documentId,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasCompletedDocument(true);
|
|
||||||
} catch (err) {
|
|
||||||
if (window.parent) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-error',
|
|
||||||
data: null,
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(
|
|
||||||
msg`We were unable to submit this document at this time. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDocumentRejected = (reason: string) => {
|
|
||||||
if (window.parent) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-rejected',
|
|
||||||
data: {
|
|
||||||
token,
|
|
||||||
documentId,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
reason,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasRejectedDocument(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const hash = window.location.hash.slice(1);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
|
||||||
|
|
||||||
if (!isCompleted && data.name) {
|
|
||||||
setFullName(data.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since a recipient can be provided a name we can lock it without requiring
|
|
||||||
// a to be provided by the parent application, unlike direct templates.
|
|
||||||
setIsNameLocked(!!data.lockName);
|
|
||||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
|
||||||
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
|
||||||
|
|
||||||
if (data.darkModeDisabled) {
|
|
||||||
document.documentElement.classList.add('dark-mode-disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allowWhitelabelling) {
|
|
||||||
injectCss({
|
|
||||||
css: data.css,
|
|
||||||
cssVars: data.cssVars,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasFinishedInit(true);
|
|
||||||
|
|
||||||
// !: While the two setters are stable we still want to ensure we're avoiding
|
|
||||||
// !: re-renders.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-ready',
|
|
||||||
data: null,
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
|
||||||
|
|
||||||
if (hasRejectedDocument) {
|
|
||||||
return <EmbedDocumentRejected />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasCompletedDocument) {
|
|
||||||
return (
|
|
||||||
<EmbedDocumentCompleted
|
|
||||||
name={fullName}
|
|
||||||
signature={{
|
|
||||||
id: 1,
|
|
||||||
fieldId: 1,
|
|
||||||
recipientId: 1,
|
|
||||||
created: new Date(),
|
|
||||||
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
|
||||||
typedSignature: signature?.startsWith('data:') ? null : signature,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
|
||||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
|
||||||
|
|
||||||
{allowDocumentRejection && (
|
|
||||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
|
||||||
<DocumentSigningRejectDialog
|
|
||||||
document={{ id: documentId }}
|
|
||||||
token={token}
|
|
||||||
onRejected={onDocumentRejected}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
|
||||||
{/* Viewer */}
|
|
||||||
<div className="embed--DocumentViewer flex-1">
|
|
||||||
<PDFViewer
|
|
||||||
documentData={documentData}
|
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Widget */}
|
|
||||||
<div
|
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
|
||||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit 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">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="embed--DocumentWidgetHeader">
|
|
||||||
<div className="flex items-center justify-between gap-x-2">
|
|
||||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
|
||||||
{isAssistantMode ? (
|
|
||||||
<Trans>Assist with signing</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Sign document</Trans>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
|
||||||
{isExpanded ? (
|
|
||||||
<LucideChevronDown
|
|
||||||
className="text-muted-foreground h-5 w-5"
|
|
||||||
onClick={() => setIsExpanded(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LucideChevronUp
|
|
||||||
className="text-muted-foreground h-5 w-5"
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
{isAssistantMode ? (
|
|
||||||
<Trans>Help complete the document for other signers.</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Sign the document to complete the process.</Trans>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
|
||||||
{isAssistantMode && (
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
<Trans>Signing for</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
|
|
||||||
<RadioGroup
|
|
||||||
className="gap-0 space-y-3 shadow-none"
|
|
||||||
value={selectedSignerId?.toString()}
|
|
||||||
onValueChange={(value) => setSelectedSignerId(Number(value))}
|
|
||||||
>
|
|
||||||
{allRecipients
|
|
||||||
.filter((r) => r.fields.length > 0)
|
|
||||||
.map((r) => (
|
|
||||||
<div
|
|
||||||
key={`${assistantSignersId}-${r.id}`}
|
|
||||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<RadioGroupItem
|
|
||||||
id={`${assistantSignersId}-${r.id}`}
|
|
||||||
value={r.id.toString()}
|
|
||||||
className="after:absolute after:inset-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grow gap-1">
|
|
||||||
<Label
|
|
||||||
className="inline-flex items-start"
|
|
||||||
htmlFor={`${assistantSignersId}-${r.id}`}
|
|
||||||
>
|
|
||||||
{r.name}
|
|
||||||
|
|
||||||
{r.id === recipient.id && (
|
|
||||||
<span className="text-muted-foreground ml-2">
|
|
||||||
{_(msg`(You)`)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
|
||||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isAssistantMode && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="full-name">
|
|
||||||
<Trans>Full Name</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="full-name"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
disabled={isNameLocked}
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
value={email}
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasSignatureField && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="Signature">
|
|
||||||
<Trans>Signature</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<SignaturePadDialog
|
|
||||||
className="mt-2"
|
|
||||||
disabled={isThrottled || isSubmitting}
|
|
||||||
disableAnimation
|
|
||||||
value={signature ?? ''}
|
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
|
||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
|
||||||
|
|
||||||
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
|
||||||
{pendingFields.length > 0 ? (
|
|
||||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
|
||||||
<Trans>Next</Trans>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
|
||||||
disabled={isThrottled}
|
|
||||||
loading={isSubmitting}
|
|
||||||
onClick={() => throttledOnCompleteClick()}
|
|
||||||
>
|
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
|
||||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
|
||||||
<Trans>Click to insert field</Trans>
|
|
||||||
</FieldToolTip>
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
|
|
||||||
{/* Fields */}
|
|
||||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
|
||||||
|
|
||||||
{/* Completed fields */}
|
|
||||||
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!hidePoweredBy && (
|
|
||||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
|
||||||
<span>Powered by</span>
|
|
||||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DocumentSigningRecipientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
export const EmbedDocumentWaitingForTurn = () => {
|
|
||||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (window.parent && !hasPostedMessage) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{
|
|
||||||
action: 'document-waiting-for-turn',
|
|
||||||
data: null,
|
|
||||||
},
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasPostedMessage(true);
|
|
||||||
}, [hasPostedMessage]);
|
|
||||||
|
|
||||||
if (!hasPostedMessage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
|
||||||
<h3 className="text-foreground text-center text-2xl font-bold">
|
|
||||||
<Trans>Waiting for Your Turn</Trans>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="mt-8 max-w-[50ch] text-center">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans>
|
|
||||||
It's currently not your turn to sign. Please check back soon as this document should be
|
|
||||||
available for you to sign shortly.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 text-sm">
|
|
||||||
<Trans>Please check with the parent application for more information.</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,363 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { 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 { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
|
||||||
import {
|
|
||||||
SUPPORTED_LANGUAGES,
|
|
||||||
SUPPORTED_LANGUAGE_CODES,
|
|
||||||
isValidLanguageCode,
|
|
||||||
} from '@documenso/lib/constants/i18n';
|
|
||||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
|
||||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can't infer this from the schema since we need to keep the schema inside the component to allow
|
|
||||||
* it to be dynamic.
|
|
||||||
*/
|
|
||||||
export type TDocumentPreferencesFormSchema = {
|
|
||||||
documentVisibility: DocumentVisibility | null;
|
|
||||||
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
|
|
||||||
includeSenderDetails: boolean | null;
|
|
||||||
includeSigningCertificate: boolean | null;
|
|
||||||
signatureTypes: DocumentSignatureType[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type SettingsSubset = Pick<
|
|
||||||
TeamGlobalSettings,
|
|
||||||
| 'documentVisibility'
|
|
||||||
| 'documentLanguage'
|
|
||||||
| 'includeSenderDetails'
|
|
||||||
| 'includeSigningCertificate'
|
|
||||||
| 'typedSignatureEnabled'
|
|
||||||
| 'uploadSignatureEnabled'
|
|
||||||
| 'drawSignatureEnabled'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type DocumentPreferencesFormProps = {
|
|
||||||
settings: SettingsSubset;
|
|
||||||
canInherit: boolean;
|
|
||||||
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentPreferencesForm = ({
|
|
||||||
settings,
|
|
||||||
onFormSubmit,
|
|
||||||
canInherit,
|
|
||||||
}: DocumentPreferencesFormProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { user } = useSession();
|
|
||||||
|
|
||||||
const placeholderEmail = user.email ?? 'user@example.com';
|
|
||||||
|
|
||||||
const ZDocumentPreferencesFormSchema = z.object({
|
|
||||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
|
||||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
|
||||||
includeSenderDetails: z.boolean().nullable(),
|
|
||||||
includeSigningCertificate: z.boolean().nullable(),
|
|
||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<TDocumentPreferencesFormSchema>({
|
|
||||||
defaultValues: {
|
|
||||||
documentVisibility: settings.documentVisibility,
|
|
||||||
documentLanguage: isValidLanguageCode(settings.documentLanguage)
|
|
||||||
? settings.documentLanguage
|
|
||||||
: null,
|
|
||||||
includeSenderDetails: settings.includeSenderDetails,
|
|
||||||
includeSigningCertificate: settings.includeSigningCertificate,
|
|
||||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
|
||||||
},
|
|
||||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset
|
|
||||||
className="flex h-full max-w-2xl flex-col gap-y-6"
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="documentVisibility"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Default Document Visibility</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '-1' : field.value}
|
|
||||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
|
||||||
<Trans>Everyone can access and view the document</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
|
||||||
<Trans>Only managers and above can access and view the document</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
|
||||||
<Trans>Only admins can access and view the document</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
{canInherit && (
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>Inherit from organisation</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Controls the default visibility of an uploaded document.</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="documentLanguage"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Default Document Language</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '-1' : field.value}
|
|
||||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
|
|
||||||
<SelectItem key={code} value={code}>
|
|
||||||
{language.full}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>Inherit from organisation</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls the default language of an uploaded document. This will be used as the
|
|
||||||
language in email communications with the recipients.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="signatureTypes"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel className="flex flex-row items-center">
|
|
||||||
<Trans>Default Signature Settings</Trans>
|
|
||||||
<DocumentSignatureSettingsTooltip />
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<MultiSelectCombobox
|
|
||||||
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
|
|
||||||
label: t(option.label),
|
|
||||||
value: option.value,
|
|
||||||
}))}
|
|
||||||
selectedValues={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
className="bg-background w-full"
|
|
||||||
enableSearch={false}
|
|
||||||
emptySelectionPlaceholder={
|
|
||||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
|
||||||
}
|
|
||||||
testId="signature-types-combobox"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
{form.formState.errors.signatureTypes ? (
|
|
||||||
<FormMessage />
|
|
||||||
) : (
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls which signatures are allowed to be used when signing a document.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="includeSenderDetails"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Send on Behalf of Team</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '-1' : field.value.toString()}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true">
|
|
||||||
<Trans>Yes</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value="false">
|
|
||||||
<Trans>No</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
{canInherit && (
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>Inherit from organisation</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<div className="text-muted-foreground text-xs font-medium">
|
|
||||||
<Trans>Preview</Trans>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert variant="neutral" className="mt-1 px-2.5 py-1.5 text-sm">
|
|
||||||
{field.value ? (
|
|
||||||
<Trans>
|
|
||||||
"{placeholderEmail}" on behalf of "Team Name" has invited you to sign
|
|
||||||
"example document".
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>"Team Name" has invited you to sign "example document".</Trans>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls the formatting of the message that will be sent when inviting a
|
|
||||||
recipient to sign a document. If a custom message has been provided while
|
|
||||||
configuring the document, it will be used instead.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="includeSigningCertificate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Include the Signing Certificate in the Document</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Select
|
|
||||||
{...field}
|
|
||||||
value={field.value === null ? '-1' : field.value.toString()}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="bg-background text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="true">
|
|
||||||
<Trans>Yes</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
<SelectItem value="false">
|
|
||||||
<Trans>No</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
|
|
||||||
{canInherit && (
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>Inherit from organisation</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>
|
|
||||||
Controls whether the signing certificate will be included in the document when
|
|
||||||
it is downloaded. The signing certificate can still be downloaded from the logs
|
|
||||||
page separately.
|
|
||||||
</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
<Trans>Update</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
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 { ZUpdateOrganisationRequestSchema } from '@documenso/trpc/server/organisation-router/update-organisation.types';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
const ZOrganisationUpdateFormSchema = ZUpdateOrganisationRequestSchema.shape.data.pick({
|
|
||||||
name: true,
|
|
||||||
url: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
type TOrganisationUpdateFormSchema = z.infer<typeof ZOrganisationUpdateFormSchema>;
|
|
||||||
|
|
||||||
export const OrganisationUpdateForm = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZOrganisationUpdateFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: organisation.name,
|
|
||||||
url: organisation.url,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateOrganisation } = trpc.organisation.update.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, url }: TOrganisationUpdateFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updateOrganisation({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
url,
|
|
||||||
},
|
|
||||||
organisationId: organisation.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Your organisation has been successfully updated.`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (url !== organisation.url) {
|
|
||||||
await navigate(`/org/${url}/settings`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: _(msg`This URL is already in use.`),
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to update your organisation. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="mt-4">
|
|
||||||
<FormLabel required>
|
|
||||||
<Trans>Organisation URL</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
{!form.formState.errors.url && (
|
|
||||||
<span className="text-foreground/50 text-xs font-normal">
|
|
||||||
{field.value ? (
|
|
||||||
`${NEXT_PUBLIC_WEBAPP_URL()}/org/${field.value}`
|
|
||||||
) : (
|
|
||||||
<Trans>A unique URL to identify your organisation</Trans>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-row justify-end space-x-4">
|
|
||||||
<AnimatePresence>
|
|
||||||
{form.formState.isDirty && (
|
|
||||||
<motion.div
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
|
||||||
<Trans>Reset</Trans>
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="transition-opacity"
|
|
||||||
disabled={!form.formState.isDirty}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Update organisation</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,394 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { UserProfileTimur } from '~/components/general/user-profile-timur';
|
|
||||||
|
|
||||||
export const ZSignUpFormSchema = z
|
|
||||||
.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
|
||||||
email: z.string().email().min(1),
|
|
||||||
password: ZPasswordSchema,
|
|
||||||
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
const { name, email, password } = data;
|
|
||||||
return !password.includes(name) && !password.includes(email.split('@')[0]);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: msg`Password should not be common or based on personal information`.id,
|
|
||||||
path: ['password'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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.`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
|
||||||
|
|
||||||
export type SignUpFormProps = {
|
|
||||||
className?: string;
|
|
||||||
initialEmail?: string;
|
|
||||||
isGoogleSSOEnabled?: boolean;
|
|
||||||
isOIDCSSOEnabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SignUpForm = ({
|
|
||||||
className,
|
|
||||||
initialEmail,
|
|
||||||
isGoogleSSOEnabled,
|
|
||||||
isOIDCSSOEnabled,
|
|
||||||
}: SignUpFormProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
|
||||||
values: {
|
|
||||||
name: '',
|
|
||||||
email: initialEmail ?? '',
|
|
||||||
password: '',
|
|
||||||
signature: '',
|
|
||||||
},
|
|
||||||
mode: 'onBlur',
|
|
||||||
resolver: zodResolver(ZSignUpFormSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
|
||||||
try {
|
|
||||||
await authClient.emailPassword.signUp({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
signature,
|
|
||||||
});
|
|
||||||
|
|
||||||
await navigate(`/unverified-account`);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Registration Successful`),
|
|
||||||
description: _(
|
|
||||||
msg`You have successfully registered. Please verify your account by clicking on the link you received in the email.`,
|
|
||||||
),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
analytics.capture('App: User Sign Up', {
|
|
||||||
email,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
custom_campaign_params: { src: utmSrc },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`An error occurred`),
|
|
||||||
description: _(errorMessage),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSignUpWithGoogleClick = async () => {
|
|
||||||
try {
|
|
||||||
await authClient.google.signIn();
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSignUpWithOIDCClick = async () => {
|
|
||||||
try {
|
|
||||||
await authClient.oidc.signIn();
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`An unknown error occurred`),
|
|
||||||
description: _(
|
|
||||||
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hash = window.location.hash.slice(1);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(hash);
|
|
||||||
|
|
||||||
const email = params.get('email');
|
|
||||||
|
|
||||||
if (email) {
|
|
||||||
form.setValue('email', email);
|
|
||||||
}
|
|
||||||
}, [form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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">
|
|
||||||
<img
|
|
||||||
src={communityCardsImage}
|
|
||||||
alt="community-cards"
|
|
||||||
className="h-full w-full object-cover dark:brightness-95 dark:contrast-[70%] dark:invert"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-background/50 absolute -inset-8 -z-[1] backdrop-blur-[2px]" />
|
|
||||||
|
|
||||||
<div className="relative flex h-full w-full flex-col items-center justify-evenly">
|
|
||||||
<div className="bg-background rounded-2xl border px-4 py-1 text-sm font-medium">
|
|
||||||
<Trans>User profiles are here!</Trans>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<UserProfileTimur
|
|
||||||
rows={2}
|
|
||||||
className="bg-background border-border rounded-2xl border shadow-md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border dark:bg-background relative z-10 flex min-h-[min(850px,80vh)] w-full max-w-lg flex-col rounded-xl border bg-neutral-100 p-6">
|
|
||||||
<div className="h-20">
|
|
||||||
<h1 className="text-xl font-semibold md:text-2xl">
|
|
||||||
<Trans>Create a new account</Trans>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
|
|
||||||
<Trans>
|
|
||||||
Create your account and start using state-of-the-art document signing. Open and
|
|
||||||
beautiful signing is within your grasp.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="-mx-6 my-4" />
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
className="flex w-full flex-1 flex-col gap-y-4"
|
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
|
||||||
>
|
|
||||||
<fieldset
|
|
||||||
className={cn(
|
|
||||||
'flex h-[550px] w-full flex-col gap-y-4',
|
|
||||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
|
||||||
)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Full Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="text" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Email Address</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="email" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Password</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="signature"
|
|
||||||
render={({ field: { onChange, value } }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Sign Here</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<SignaturePadDialog
|
|
||||||
disabled={isSubmitting}
|
|
||||||
value={value}
|
|
||||||
onChange={(v) => onChange(v ?? '')}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(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">
|
|
||||||
<Trans>Or</Trans>
|
|
||||||
</span>
|
|
||||||
<div className="bg-border h-px flex-1" />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
variant={'outline'}
|
|
||||||
className="bg-background text-muted-foreground border"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={onSignUpWithGoogleClick}
|
|
||||||
>
|
|
||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Sign Up with Google</Trans>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
variant={'outline'}
|
|
||||||
className="bg-background text-muted-foreground border"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={onSignUpWithOIDCClick}
|
|
||||||
>
|
|
||||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Sign Up with OIDC</Trans>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Link to="/signin" className="text-documenso-700 duration-200 hover:opacity-70">
|
|
||||||
Sign in instead
|
|
||||||
</Link>
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
disabled={!form.formState.isValid}
|
|
||||||
type="submit"
|
|
||||||
size="lg"
|
|
||||||
className="mt-6 w-full"
|
|
||||||
>
|
|
||||||
<Trans>Complete</Trans>
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
<p className="text-muted-foreground mt-6 text-xs">
|
|
||||||
<Trans>
|
|
||||||
By proceeding, you agree to our{' '}
|
|
||||||
<Link
|
|
||||||
to="https://documen.so/terms"
|
|
||||||
target="_blank"
|
|
||||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
|
||||||
>
|
|
||||||
Terms of Service
|
|
||||||
</Link>{' '}
|
|
||||||
and{' '}
|
|
||||||
<Link
|
|
||||||
to="https://documen.so/privacy"
|
|
||||||
target="_blank"
|
|
||||||
className="text-documenso-700 duration-200 hover:opacity-70"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
|
||||||
import type { SubscriptionClaim } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
|
||||||
import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types';
|
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
|
|
||||||
export type SubscriptionClaimFormValues = z.infer<typeof ZCreateSubscriptionClaimRequestSchema>;
|
|
||||||
|
|
||||||
type SubscriptionClaimFormProps = {
|
|
||||||
subscriptionClaim: Omit<SubscriptionClaim, 'id' | 'createdAt' | 'updatedAt'>;
|
|
||||||
onFormSubmit: (data: SubscriptionClaimFormValues) => Promise<void>;
|
|
||||||
formSubmitTrigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SubscriptionClaimForm = ({
|
|
||||||
subscriptionClaim,
|
|
||||||
onFormSubmit,
|
|
||||||
formSubmitTrigger,
|
|
||||||
}: SubscriptionClaimFormProps) => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const form = useForm<SubscriptionClaimFormValues>({
|
|
||||||
resolver: zodResolver(ZCreateSubscriptionClaimRequestSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: subscriptionClaim.name,
|
|
||||||
teamCount: subscriptionClaim.teamCount,
|
|
||||||
memberCount: subscriptionClaim.memberCount,
|
|
||||||
flags: subscriptionClaim.flags,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={t`Enter claim name`} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="teamCount"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Team Count</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="memberCount"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Member Count</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Feature Flags</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
|
||||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => (
|
|
||||||
<FormField
|
|
||||||
key={key}
|
|
||||||
control={form.control}
|
|
||||||
name={`flags.${key}`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center space-x-2">
|
|
||||||
<FormControl>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Checkbox
|
|
||||||
id={`flag-${key}`}
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<label
|
|
||||||
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
|
|
||||||
htmlFor={`flag-${key}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formSubmitTrigger}
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import { type TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
|
|
||||||
|
|
||||||
export type AppBannerProps = {
|
|
||||||
banner: TSiteSettingsBannerSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppBanner = ({ banner }: AppBannerProps) => {
|
|
||||||
if (!banner.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-2" style={{ background: banner.data.bgColor }}>
|
|
||||||
<div
|
|
||||||
className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
|
|
||||||
style={{ color: banner.data.textColor }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Banner
|
|
||||||
// Custom Text
|
|
||||||
// Custom Text with Custom Icon
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { AnimatePresence } from 'framer-motion';
|
|
||||||
import { Search } from 'lucide-react';
|
|
||||||
import { Link, useLocation, useParams } from 'react-router';
|
|
||||||
|
|
||||||
import { getRootHref } from '@documenso/lib/utils/params';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
const navigationLinks = [
|
|
||||||
{
|
|
||||||
href: '/documents',
|
|
||||||
label: msg`Documents`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/templates',
|
|
||||||
label: msg`Templates`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export type AppNavDesktopProps = HTMLAttributes<HTMLDivElement> & {
|
|
||||||
setIsCommandMenuOpen: (value: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppNavDesktop = ({
|
|
||||||
className,
|
|
||||||
setIsCommandMenuOpen,
|
|
||||||
...props
|
|
||||||
}: AppNavDesktopProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
|
||||||
|
|
||||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
|
||||||
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
|
||||||
|
|
||||||
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<AnimatePresence>
|
|
||||||
{params.teamUrl && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="flex items-baseline gap-x-6"
|
|
||||||
>
|
|
||||||
{navigationLinks.map(({ href, label }) => (
|
|
||||||
<Link
|
|
||||||
key={href}
|
|
||||||
to={`${rootHref}${href}`}
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground dark:text-muted-foreground/60 focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
|
||||||
{
|
|
||||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
|
||||||
`${rootHref}${href}`,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{_(label)}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="text-muted-foreground flex w-full max-w-96 items-center justify-between rounded-lg"
|
|
||||||
onClick={() => setIsCommandMenuOpen(true)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Search className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Search</Trans>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
|
|
||||||
{modifierKey}+K
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,314 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { Field } from '@prisma/client';
|
|
||||||
import { RecipientRole } from '@prisma/client';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
|
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
|
||||||
|
|
||||||
export type DocumentSigningCompleteDialogProps = {
|
|
||||||
isSubmitting: boolean;
|
|
||||||
documentTitle: string;
|
|
||||||
fields: Field[];
|
|
||||||
fieldsValidated: () => void | Promise<void>;
|
|
||||||
onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise<void>;
|
|
||||||
role: RecipientRole;
|
|
||||||
disabled?: boolean;
|
|
||||||
allowDictateNextSigner?: boolean;
|
|
||||||
defaultNextSigner?: {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const ZNextSignerFormSchema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
email: z.string().email('Invalid email address'),
|
|
||||||
});
|
|
||||||
|
|
||||||
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
|
|
||||||
|
|
||||||
export const DocumentSigningCompleteDialog = ({
|
|
||||||
isSubmitting,
|
|
||||||
documentTitle,
|
|
||||||
fields,
|
|
||||||
fieldsValidated,
|
|
||||||
onSignatureComplete,
|
|
||||||
role,
|
|
||||||
disabled = false,
|
|
||||||
allowDictateNextSigner = false,
|
|
||||||
defaultNextSigner,
|
|
||||||
}: DocumentSigningCompleteDialogProps) => {
|
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
|
||||||
const [isEditingNextSigner, setIsEditingNextSigner] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
|
||||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]);
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
if (form.formState.isSubmitting || !isComplete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (open) {
|
|
||||||
form.reset({
|
|
||||||
name: defaultNextSigner?.name ?? '',
|
|
||||||
email: defaultNextSigner?.email ?? '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsEditingNextSigner(false);
|
|
||||||
setShowDialog(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFormSubmit = async (data: TNextSignerFormSchema) => {
|
|
||||||
console.log('data', data);
|
|
||||||
console.log('form.formState.errors', form.formState.errors);
|
|
||||||
try {
|
|
||||||
if (allowDictateNextSigner && data.name && data.email) {
|
|
||||||
await onSignatureComplete({ name: data.name, email: data.email });
|
|
||||||
} else {
|
|
||||||
await onSignatureComplete();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error completing signature:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email'));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
type="button"
|
|
||||||
size="lg"
|
|
||||||
onClick={fieldsValidated}
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{match({ isComplete, role })
|
|
||||||
.with({ isComplete: false }, () => <Trans>Next field</Trans>)
|
|
||||||
.with({ isComplete: true, role: RecipientRole.APPROVER }, () => <Trans>Approve</Trans>)
|
|
||||||
.with({ isComplete: true, role: RecipientRole.VIEWER }, () => (
|
|
||||||
<Trans>Mark as viewed</Trans>
|
|
||||||
))
|
|
||||||
.with({ isComplete: true }, () => <Trans>Complete</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
|
|
||||||
<DialogTitle>
|
|
||||||
<div className="text-foreground text-xl font-semibold">
|
|
||||||
{match(role)
|
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Complete Viewing</Trans>)
|
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Complete Signing</Trans>)
|
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Complete Approval</Trans>)
|
|
||||||
.with(RecipientRole.CC, () => <Trans>Complete Viewing</Trans>)
|
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete Assisting</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</div>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground max-w-[50ch]">
|
|
||||||
{match(role)
|
|
||||||
.with(RecipientRole.VIEWER, () => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete viewing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.SIGNER, () => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete signing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.APPROVER, () => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete approving{' '}
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
"{documentTitle}"
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<span>
|
|
||||||
<Trans>
|
|
||||||
<span className="inline-flex flex-wrap">
|
|
||||||
You are about to complete viewing "
|
|
||||||
<span className="inline-block max-w-[11rem] truncate align-baseline">
|
|
||||||
{documentTitle}
|
|
||||||
</span>
|
|
||||||
".
|
|
||||||
</span>
|
|
||||||
<br /> Are you sure?
|
|
||||||
</Trans>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{allowDictateNextSigner && (
|
|
||||||
<div className="mt-4 flex flex-col gap-4">
|
|
||||||
{!isEditingNextSigner && (
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
The next recipient to sign this document will be{' '}
|
|
||||||
<span className="font-semibold">{form.watch('name')}</span> (
|
|
||||||
<span className="font-semibold">{form.watch('email')}</span>).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="mt-2"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsEditingNextSigner((prev) => !prev)}
|
|
||||||
>
|
|
||||||
<Trans>Update Recipient</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isEditingNextSigner && (
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Name</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's name"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Email</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
type="email"
|
|
||||||
className="mt-2"
|
|
||||||
placeholder="Enter the next signer's email"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DocumentSigningDisclosure className="mt-4" />
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4">
|
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setShowDialog(false)}
|
|
||||||
disabled={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1"
|
|
||||||
disabled={!isComplete || !isNextSignerValid}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
{match(role)
|
|
||||||
.with(RecipientRole.VIEWER, () => <Trans>Mark as Viewed</Trans>)
|
|
||||||
.with(RecipientRole.SIGNER, () => <Trans>Sign</Trans>)
|
|
||||||
.with(RecipientRole.APPROVER, () => <Trans>Approve</Trans>)
|
|
||||||
.with(RecipientRole.CC, () => <Trans>Mark as Viewed</Trans>)
|
|
||||||
.with(RecipientRole.ASSISTANT, () => <Trans>Complete</Trans>)
|
|
||||||
.exhaustive()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,432 +0,0 @@
|
|||||||
import { useId, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { type Field, FieldType, type Recipient, RecipientRole } from '@prisma/client';
|
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AssistantConfirmationDialog,
|
|
||||||
type NextSigner,
|
|
||||||
} from '../../dialogs/assistant-confirmation-dialog';
|
|
||||||
import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog';
|
|
||||||
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
|
||||||
|
|
||||||
export type DocumentSigningFormProps = {
|
|
||||||
document: DocumentAndSender;
|
|
||||||
recipient: Recipient;
|
|
||||||
fields: Field[];
|
|
||||||
redirectUrl?: string | null;
|
|
||||||
isRecipientsTurn: boolean;
|
|
||||||
allRecipients?: RecipientWithFields[];
|
|
||||||
setSelectedSignerId?: (id: number | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentSigningForm = ({
|
|
||||||
document,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
redirectUrl,
|
|
||||||
isRecipientsTurn,
|
|
||||||
allRecipients = [],
|
|
||||||
setSelectedSignerId,
|
|
||||||
}: DocumentSigningFormProps) => {
|
|
||||||
const { sessionData } = useOptionalSession();
|
|
||||||
const user = sessionData?.user;
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const analytics = useAnalytics();
|
|
||||||
|
|
||||||
const assistantSignersId = useId();
|
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
|
||||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] = useState(false);
|
|
||||||
const [isAssistantSubmitting, setIsAssistantSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutateAsync: completeDocumentWithToken,
|
|
||||||
isPending,
|
|
||||||
isSuccess,
|
|
||||||
} = trpc.recipient.completeDocumentWithToken.useMutation();
|
|
||||||
|
|
||||||
const assistantForm = useForm<{ selectedSignerId: number | undefined }>({
|
|
||||||
defaultValues: {
|
|
||||||
selectedSignerId: undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep the loading state going if successful since the redirect may take some time.
|
|
||||||
const isSubmitting = isPending || isSuccess;
|
|
||||||
|
|
||||||
const fieldsRequiringValidation = useMemo(
|
|
||||||
() => fields.filter(isFieldUnsignedAndRequired),
|
|
||||||
[fields],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
|
||||||
return sortFieldsByPosition(fieldsRequiringValidation.filter((field) => !field.inserted));
|
|
||||||
}, [fieldsRequiringValidation]);
|
|
||||||
|
|
||||||
const uninsertedRecipientFields = useMemo(() => {
|
|
||||||
return fieldsRequiringValidation.filter((field) => field.recipientId === recipient.id);
|
|
||||||
}, [fieldsRequiringValidation, recipient]);
|
|
||||||
|
|
||||||
const fieldsValidated = () => {
|
|
||||||
setValidateUninsertedFields(true);
|
|
||||||
validateFieldsInserted(fieldsRequiringValidation);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAssistantFormSubmit = () => {
|
|
||||||
if (uninsertedRecipientFields.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsConfirmationDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssistantConfirmDialogSubmit = async (nextSigner?: NextSigner) => {
|
|
||||||
setIsAssistantSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await completeDocument(undefined, nextSigner);
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'Error',
|
|
||||||
description: 'An error occurred while completing the document. Please try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsAssistantSubmitting(false);
|
|
||||||
setIsConfirmationDialogOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeDocument = async (
|
|
||||||
authOptions?: TRecipientActionAuth,
|
|
||||||
nextSigner?: { email: string; name: string },
|
|
||||||
) => {
|
|
||||||
const payload = {
|
|
||||||
token: recipient.token,
|
|
||||||
documentId: document.id,
|
|
||||||
authOptions,
|
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
await completeDocumentWithToken(payload);
|
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
|
||||||
signerId: recipient.id,
|
|
||||||
documentId: document.id,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (redirectUrl) {
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
} else {
|
|
||||||
await navigate(`/sign/${recipient.token}/complete`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextRecipient = useMemo(() => {
|
|
||||||
if (
|
|
||||||
!document.documentMeta?.signingOrder ||
|
|
||||||
document.documentMeta.signingOrder !== 'SEQUENTIAL'
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedRecipients = allRecipients.sort((a, b) => {
|
|
||||||
// Sort by signingOrder first (nulls last), then by id
|
|
||||||
if (a.signingOrder === null && b.signingOrder === null) return a.id - b.id;
|
|
||||||
if (a.signingOrder === null) return 1;
|
|
||||||
if (b.signingOrder === null) return -1;
|
|
||||||
if (a.signingOrder === b.signingOrder) return a.id - b.id;
|
|
||||||
return a.signingOrder - b.signingOrder;
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentIndex = sortedRecipients.findIndex((r) => r.id === recipient.id);
|
|
||||||
return currentIndex !== -1 && currentIndex < sortedRecipients.length - 1
|
|
||||||
? sortedRecipients[currentIndex + 1]
|
|
||||||
: undefined;
|
|
||||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
|
||||||
{
|
|
||||||
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': user,
|
|
||||||
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{validateUninsertedFields && uninsertedFields[0] && (
|
|
||||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
|
||||||
<Trans>Click to insert field</Trans>
|
|
||||||
</FieldToolTip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<h3 className="text-foreground text-2xl font-semibold">
|
|
||||||
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
|
||||||
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
|
||||||
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
|
||||||
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{recipient.role === RecipientRole.VIEWER ? (
|
|
||||||
<>
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>Please mark as viewed to complete</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
|
|
||||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
|
||||||
<div className="flex flex-1 flex-col gap-y-4" />
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={async () => navigate(-1)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
onSignatureComplete={async (nextSigner) => {
|
|
||||||
await completeDocument(undefined, nextSigner);
|
|
||||||
}}
|
|
||||||
role={recipient.role}
|
|
||||||
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
|
|
||||||
defaultNextSigner={
|
|
||||||
nextRecipient
|
|
||||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : recipient.role === RecipientRole.ASSISTANT ? (
|
|
||||||
<>
|
|
||||||
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
<Trans>
|
|
||||||
Complete the fields for the following signers. Once reviewed, they will inform
|
|
||||||
you if any modifications are needed.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border my-4" />
|
|
||||||
|
|
||||||
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
|
||||||
<Controller
|
|
||||||
name="selectedSignerId"
|
|
||||||
control={assistantForm.control}
|
|
||||||
rules={{ required: 'Please select a signer' }}
|
|
||||||
render={({ field }) => (
|
|
||||||
<RadioGroup
|
|
||||||
className="gap-0 space-y-3 shadow-none"
|
|
||||||
value={field.value?.toString()}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
setSelectedSignerId?.(Number(value));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allRecipients
|
|
||||||
.filter((r) => r.fields.length > 0)
|
|
||||||
.map((r) => (
|
|
||||||
<div
|
|
||||||
key={`${assistantSignersId}-${r.id}`}
|
|
||||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<RadioGroupItem
|
|
||||||
id={`${assistantSignersId}-${r.id}`}
|
|
||||||
value={r.id.toString()}
|
|
||||||
className="after:absolute after:inset-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grow gap-1">
|
|
||||||
<Label
|
|
||||||
className="inline-flex items-start"
|
|
||||||
htmlFor={`${assistantSignersId}-${r.id}`}
|
|
||||||
>
|
|
||||||
{r.name}
|
|
||||||
|
|
||||||
{r.id === recipient.id && (
|
|
||||||
<span className="text-muted-foreground ml-2">
|
|
||||||
{_(msg`(You)`)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Label>
|
|
||||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
|
||||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
loading={isAssistantSubmitting}
|
|
||||||
>
|
|
||||||
<Trans>Continue</Trans>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AssistantConfirmationDialog
|
|
||||||
hasUninsertedFields={uninsertedFields.length > 0}
|
|
||||||
isOpen={isConfirmationDialogOpen}
|
|
||||||
onClose={() => !isAssistantSubmitting && setIsConfirmationDialogOpen(false)}
|
|
||||||
onConfirm={handleAssistantConfirmDialogSubmit}
|
|
||||||
isSubmitting={isAssistantSubmitting}
|
|
||||||
allowDictateNextSigner={
|
|
||||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
|
||||||
}
|
|
||||||
defaultNextSigner={
|
|
||||||
nextRecipient
|
|
||||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
|
||||||
<Trans>Please review the document before approving.</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>Please review the document before signing.</Trans>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border mb-8 mt-4" />
|
|
||||||
|
|
||||||
<fieldset
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-1 flex-col gap-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="full-name">
|
|
||||||
<Trans>Full Name</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="full-name"
|
|
||||||
className="bg-background mt-2"
|
|
||||||
value={fullName}
|
|
||||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasSignatureField && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="Signature">
|
|
||||||
<Trans>Signature</Trans>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
<SignaturePadDialog
|
|
||||||
className="mt-2"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
value={signature ?? ''}
|
|
||||||
onChange={(v) => setSignature(v ?? '')}
|
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
|
||||||
variant="secondary"
|
|
||||||
size="lg"
|
|
||||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
|
||||||
onClick={async () => navigate(-1)}
|
|
||||||
>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DocumentSigningCompleteDialog
|
|
||||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
|
||||||
documentTitle={document.title}
|
|
||||||
fields={fields}
|
|
||||||
fieldsValidated={fieldsValidated}
|
|
||||||
disabled={!isRecipientsTurn}
|
|
||||||
onSignatureComplete={async (nextSigner) => {
|
|
||||||
await completeDocument(undefined, nextSigner);
|
|
||||||
}}
|
|
||||||
role={recipient.role}
|
|
||||||
allowDictateNextSigner={
|
|
||||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
|
||||||
}
|
|
||||||
defaultNextSigner={
|
|
||||||
nextRecipient
|
|
||||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import type { Field } from '@prisma/client';
|
|
||||||
import { FieldType, RecipientRole } from '@prisma/client';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
||||||
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
|
||||||
import {
|
|
||||||
ZCheckboxFieldMeta,
|
|
||||||
ZDropdownFieldMeta,
|
|
||||||
ZNumberFieldMeta,
|
|
||||||
ZRadioFieldMeta,
|
|
||||||
ZTextFieldMeta,
|
|
||||||
} from '@documenso/lib/types/field-meta';
|
|
||||||
import type { CompletedField } from '@documenso/lib/types/fields';
|
|
||||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
|
||||||
|
|
||||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
|
||||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
|
||||||
import { DocumentSigningDropdownField } from '~/components/general/document-signing/document-signing-dropdown-field';
|
|
||||||
import { DocumentSigningEmailField } from '~/components/general/document-signing/document-signing-email-field';
|
|
||||||
import { DocumentSigningForm } from '~/components/general/document-signing/document-signing-form';
|
|
||||||
import { DocumentSigningInitialsField } from '~/components/general/document-signing/document-signing-initials-field';
|
|
||||||
import { DocumentSigningNameField } from '~/components/general/document-signing/document-signing-name-field';
|
|
||||||
import { DocumentSigningNumberField } from '~/components/general/document-signing/document-signing-number-field';
|
|
||||||
import { DocumentSigningRadioField } from '~/components/general/document-signing/document-signing-radio-field';
|
|
||||||
import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog';
|
|
||||||
import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field';
|
|
||||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
|
||||||
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
|
|
||||||
|
|
||||||
import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider';
|
|
||||||
|
|
||||||
export type DocumentSigningPageViewProps = {
|
|
||||||
recipient: RecipientWithFields;
|
|
||||||
document: DocumentAndSender;
|
|
||||||
fields: Field[];
|
|
||||||
completedFields: CompletedField[];
|
|
||||||
isRecipientsTurn: boolean;
|
|
||||||
allRecipients?: RecipientWithFields[];
|
|
||||||
includeSenderDetails: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DocumentSigningPageView = ({
|
|
||||||
recipient,
|
|
||||||
document,
|
|
||||||
fields,
|
|
||||||
completedFields,
|
|
||||||
isRecipientsTurn,
|
|
||||||
allRecipients = [],
|
|
||||||
includeSenderDetails,
|
|
||||||
}: DocumentSigningPageViewProps) => {
|
|
||||||
const { documentData, documentMeta } = document;
|
|
||||||
|
|
||||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
|
||||||
|
|
||||||
let senderName = document.user.name ?? '';
|
|
||||||
let senderEmail = `(${document.user.email})`;
|
|
||||||
|
|
||||||
if (includeSenderDetails) {
|
|
||||||
senderName = document.team?.name ?? '';
|
|
||||||
senderEmail = document.team?.teamEmail?.email ? `(${document.team.teamEmail.email})` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedSigner = allRecipients?.find((r) => r.id === selectedSignerId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
|
||||||
<h1
|
|
||||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
|
||||||
title={document.title}
|
|
||||||
>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
|
||||||
<div className="max-w-[50ch]">
|
|
||||||
<span className="text-muted-foreground truncate" title={senderName}>
|
|
||||||
{senderName} {senderEmail}
|
|
||||||
</span>{' '}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{match(recipient.role)
|
|
||||||
.with(RecipientRole.VIEWER, () =>
|
|
||||||
includeSenderDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to view this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to view this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with(RecipientRole.SIGNER, () =>
|
|
||||||
includeSenderDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to sign this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to sign this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with(RecipientRole.APPROVER, () =>
|
|
||||||
includeSenderDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to approve this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to approve this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with(RecipientRole.ASSISTANT, () =>
|
|
||||||
includeSenderDetails ? (
|
|
||||||
<Trans>
|
|
||||||
on behalf of "{document.team?.name}" has invited you to assist this document
|
|
||||||
</Trans>
|
|
||||||
) : (
|
|
||||||
<Trans>has invited you to assist this document</Trans>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.otherwise(() => null)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
|
||||||
<Card
|
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
|
||||||
gradient
|
|
||||||
>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
|
||||||
<DocumentSigningForm
|
|
||||||
document={document}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
redirectUrl={documentMeta?.redirectUrl}
|
|
||||||
isRecipientsTurn={isRecipientsTurn}
|
|
||||||
allRecipients={allRecipients}
|
|
||||||
setSelectedSignerId={setSelectedSignerId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentReadOnlyFields documentMeta={documentMeta || undefined} fields={completedFields} />
|
|
||||||
|
|
||||||
{recipient.role !== RecipientRole.ASSISTANT && (
|
|
||||||
<DocumentSigningAutoSign recipient={recipient} fields={fields} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{fields
|
|
||||||
.filter(
|
|
||||||
(field) =>
|
|
||||||
recipient.role !== RecipientRole.ASSISTANT ||
|
|
||||||
field.recipientId === selectedSigner?.id,
|
|
||||||
)
|
|
||||||
.map((field) =>
|
|
||||||
match(field.type)
|
|
||||||
.with(FieldType.SIGNATURE, () => (
|
|
||||||
<DocumentSigningSignatureField
|
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with(FieldType.INITIALS, () => (
|
|
||||||
<DocumentSigningInitialsField key={field.id} field={field} />
|
|
||||||
))
|
|
||||||
.with(FieldType.NAME, () => (
|
|
||||||
<DocumentSigningNameField key={field.id} field={field} />
|
|
||||||
))
|
|
||||||
.with(FieldType.DATE, () => (
|
|
||||||
<DocumentSigningDateField
|
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
|
||||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with(FieldType.EMAIL, () => (
|
|
||||||
<DocumentSigningEmailField key={field.id} field={field} />
|
|
||||||
))
|
|
||||||
.with(FieldType.TEXT, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <DocumentSigningTextField key={field.id} field={fieldWithMeta} />;
|
|
||||||
})
|
|
||||||
.with(FieldType.NUMBER, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <DocumentSigningNumberField key={field.id} field={fieldWithMeta} />;
|
|
||||||
})
|
|
||||||
.with(FieldType.RADIO, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <DocumentSigningRadioField key={field.id} field={fieldWithMeta} />;
|
|
||||||
})
|
|
||||||
.with(FieldType.CHECKBOX, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <DocumentSigningCheckboxField key={field.id} field={fieldWithMeta} />;
|
|
||||||
})
|
|
||||||
.with(FieldType.DROPDOWN, () => {
|
|
||||||
const fieldWithMeta: FieldWithSignatureAndFieldMeta = {
|
|
||||||
...field,
|
|
||||||
fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null,
|
|
||||||
};
|
|
||||||
return <DocumentSigningDropdownField key={field.id} field={fieldWithMeta} />;
|
|
||||||
})
|
|
||||||
.otherwise(() => null),
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
</div>
|
|
||||||
</DocumentSigningRecipientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import { createContext, useContext, useState } from 'react';
|
|
||||||
|
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
|
||||||
|
|
||||||
export type DocumentSigningContextValue = {
|
|
||||||
fullName: string;
|
|
||||||
setFullName: (_value: string) => void;
|
|
||||||
email: string;
|
|
||||||
setEmail: (_value: string) => void;
|
|
||||||
signature: string | null;
|
|
||||||
setSignature: (_value: string | null) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DocumentSigningContext = createContext<DocumentSigningContextValue | null>(null);
|
|
||||||
|
|
||||||
export const useDocumentSigningContext = () => {
|
|
||||||
return useContext(DocumentSigningContext);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useRequiredDocumentSigningContext = () => {
|
|
||||||
const context = useDocumentSigningContext();
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('Signing context is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DocumentSigningProviderProps {
|
|
||||||
fullName?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
signature?: string | null;
|
|
||||||
typedSignatureEnabled?: boolean;
|
|
||||||
uploadSignatureEnabled?: boolean;
|
|
||||||
drawSignatureEnabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DocumentSigningProvider = ({
|
|
||||||
fullName: initialFullName,
|
|
||||||
email: initialEmail,
|
|
||||||
signature: initialSignature,
|
|
||||||
typedSignatureEnabled = true,
|
|
||||||
uploadSignatureEnabled = true,
|
|
||||||
drawSignatureEnabled = true,
|
|
||||||
children,
|
|
||||||
}: DocumentSigningProviderProps) => {
|
|
||||||
const [fullName, setFullName] = useState(initialFullName || '');
|
|
||||||
const [email, setEmail] = useState(initialEmail || '');
|
|
||||||
|
|
||||||
// Ensure the user signature doesn't show up if it's not allowed.
|
|
||||||
const [signature, setSignature] = useState(
|
|
||||||
(() => {
|
|
||||||
const sig = initialSignature || '';
|
|
||||||
const isBase64 = isBase64Image(sig);
|
|
||||||
|
|
||||||
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
|
|
||||||
return sig;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isBase64 && typedSignatureEnabled) {
|
|
||||||
return sig;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentSigningContext.Provider
|
|
||||||
value={{
|
|
||||||
fullName,
|
|
||||||
setFullName,
|
|
||||||
email,
|
|
||||||
setEmail,
|
|
||||||
signature,
|
|
||||||
setSignature,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DocumentSigningContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
DocumentSigningProvider.displayName = 'DocumentSigningProvider';
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import { type PropsWithChildren, createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
import type { Recipient } from '@prisma/client';
|
|
||||||
|
|
||||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
|
||||||
|
|
||||||
export interface DocumentSigningRecipientContextValue {
|
|
||||||
/**
|
|
||||||
* The recipient who is currently signing the document.
|
|
||||||
* In regular mode, this is the actual signer.
|
|
||||||
* In assistant mode, this is the recipient who is helping fill out the document.
|
|
||||||
*/
|
|
||||||
recipient: Recipient | RecipientWithFields;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only present in assistant mode.
|
|
||||||
* The recipient on whose behalf we're filling out the document.
|
|
||||||
*/
|
|
||||||
targetSigner: RecipientWithFields | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether we're in assistant mode (one recipient filling out for another)
|
|
||||||
*/
|
|
||||||
isAssistantMode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DocumentSigningRecipientContext = createContext<DocumentSigningRecipientContextValue | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface DocumentSigningRecipientProviderProps extends PropsWithChildren {
|
|
||||||
recipient: Recipient | RecipientWithFields;
|
|
||||||
targetSigner?: RecipientWithFields | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DocumentSigningRecipientProvider = ({
|
|
||||||
children,
|
|
||||||
recipient,
|
|
||||||
targetSigner = null,
|
|
||||||
}: DocumentSigningRecipientProviderProps) => {
|
|
||||||
// console.log({
|
|
||||||
// recipient,
|
|
||||||
// targetSigner,
|
|
||||||
// isAssistantMode: !!targetSigner,
|
|
||||||
// });
|
|
||||||
return (
|
|
||||||
<DocumentSigningRecipientContext.Provider
|
|
||||||
value={{
|
|
||||||
recipient,
|
|
||||||
targetSigner,
|
|
||||||
isAssistantMode: !!targetSigner,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DocumentSigningRecipientContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useDocumentSigningRecipientContext() {
|
|
||||||
const context = useContext(DocumentSigningRecipientContext);
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useDocumentSigningRecipientContext must be used within a RecipientProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
import type { MessageDescriptor } from '@lingui/core';
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
import { Link, useNavigate } from 'react-router';
|
|
||||||
|
|
||||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
type ErrorCodeMap = Record<
|
|
||||||
number,
|
|
||||||
{ subHeading: MessageDescriptor; heading: MessageDescriptor; message: MessageDescriptor }
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type GenericErrorLayoutProps = {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
errorCode?: number;
|
|
||||||
errorCodeMap?: ErrorCodeMap;
|
|
||||||
/**
|
|
||||||
* The primary button to display. If left as undefined, the default /documents link will be displayed.
|
|
||||||
*
|
|
||||||
* Set to null if you want no button.
|
|
||||||
*/
|
|
||||||
primaryButton?: React.ReactNode | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The secondary button to display. If left as undefined, the default back button will be displayed.
|
|
||||||
*
|
|
||||||
* Set to null if you want no button.
|
|
||||||
*/
|
|
||||||
secondaryButton?: React.ReactNode | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultErrorCodeMap: ErrorCodeMap = {
|
|
||||||
404: {
|
|
||||||
subHeading: msg`404 not found`,
|
|
||||||
heading: msg`Oops! Something went wrong.`,
|
|
||||||
message: msg`The page you are looking for was moved, removed, renamed or might never have existed.`,
|
|
||||||
},
|
|
||||||
500: {
|
|
||||||
subHeading: msg`500 Internal Server Error`,
|
|
||||||
heading: msg`Oops! Something went wrong.`,
|
|
||||||
message: msg`An unexpected error occurred.`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GenericErrorLayout = ({
|
|
||||||
children,
|
|
||||||
errorCode,
|
|
||||||
errorCodeMap = defaultErrorCodeMap,
|
|
||||||
primaryButton,
|
|
||||||
secondaryButton,
|
|
||||||
}: GenericErrorLayoutProps) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
const { subHeading, heading, message } =
|
|
||||||
errorCodeMap[errorCode || 500] ?? defaultErrorCodeMap[500];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-0 flex h-screen w-screen items-center justify-center">
|
|
||||||
<div className="absolute -inset-24 -z-10">
|
|
||||||
<motion.div
|
|
||||||
className="flex h-full w-full items-center justify-center"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={backgroundPattern}
|
|
||||||
alt="background pattern"
|
|
||||||
className="-ml-[50vw] -mt-[15vh] h-full scale-100 object-cover md:scale-100 lg:scale-[100%] dark:contrast-[70%] dark:invert dark:sepia"
|
|
||||||
style={{
|
|
||||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
|
||||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="inset-0 mx-auto flex h-full flex-grow items-center justify-center px-6 py-32">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground font-semibold">{_(subHeading)}</p>
|
|
||||||
|
|
||||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">{_(heading)}</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 text-sm">{_(message)}</p>
|
|
||||||
|
|
||||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
|
||||||
{secondaryButton ||
|
|
||||||
(secondaryButton !== null && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-32"
|
|
||||||
onClick={() => {
|
|
||||||
void navigate(-1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Go Back</Trans>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{primaryButton ||
|
|
||||||
(primaryButton !== null && (
|
|
||||||
<Button asChild>
|
|
||||||
<Link to={formatDocumentsPath(team?.url)}>
|
|
||||||
<Trans>Documents</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,350 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import {
|
|
||||||
Building2Icon,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Plus,
|
|
||||||
Settings2Icon,
|
|
||||||
SettingsIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Link, useLocation } from 'react-router';
|
|
||||||
|
|
||||||
import { authClient } from '@documenso/auth/client';
|
|
||||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
|
||||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|
||||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
||||||
import { LanguageSwitcherDialog } from '@documenso/ui/components/common/language-switcher-dialog';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
export const MenuSwitcher = () => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const { user, organisations } = useSession();
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [languageSwitcherOpen, setLanguageSwitcherOpen] = useState(false);
|
|
||||||
const [hoveredOrgId, setHoveredOrgId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const isUserAdmin = isAdmin(user);
|
|
||||||
|
|
||||||
const isPathOrgUrl = (orgUrl: string) => {
|
|
||||||
if (!pathname || !pathname.startsWith(`/org/`)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathname.split('/')[2] === orgUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedOrg = organisations.find((org) => isPathOrgUrl(org.url));
|
|
||||||
const hoveredOrg = organisations.find((org) => org.id === hoveredOrgId);
|
|
||||||
|
|
||||||
const currentOrganisation = useOptionalCurrentOrganisation();
|
|
||||||
const currentTeam = useOptionalCurrentTeam();
|
|
||||||
|
|
||||||
// Use hovered org for teams display if available,
|
|
||||||
// otherwise use current team's org if in a team,
|
|
||||||
// finally fallback to selected org
|
|
||||||
const displayedOrg = hoveredOrg || currentOrganisation || selectedOrg;
|
|
||||||
|
|
||||||
const formatAvatarFallback = (name?: string) => {
|
|
||||||
if (name !== undefined) {
|
|
||||||
return name.slice(0, 1).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats the redirect URL so we can switch between documents and templates page
|
|
||||||
* seemlessly between organisations and personal accounts.
|
|
||||||
*/
|
|
||||||
const formatRedirectUrlOnSwitch = (orgUrl?: string) => {
|
|
||||||
const baseUrl = orgUrl ? `/org/${orgUrl}` : '';
|
|
||||||
|
|
||||||
const currentPathname = (pathname ?? '/').replace(/^\/org\/[^/]+/, '');
|
|
||||||
|
|
||||||
if (currentPathname === '/templates') {
|
|
||||||
return `${baseUrl}/templates`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownMenuAvatarText = useMemo(() => {
|
|
||||||
if (currentTeam) {
|
|
||||||
return {
|
|
||||||
avatarSrc: formatAvatarUrl(currentTeam.avatarImageId),
|
|
||||||
avatarFallback: formatAvatarFallback(currentTeam.name),
|
|
||||||
primaryText: currentTeam.name,
|
|
||||||
secondaryText: _(EXTENDED_TEAM_MEMBER_ROLE_MAP[currentTeam.currentTeamRole]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentOrganisation) {
|
|
||||||
return {
|
|
||||||
avatarSrc: formatAvatarUrl(currentOrganisation.avatarImageId),
|
|
||||||
avatarFallback: formatAvatarFallback(currentOrganisation.name),
|
|
||||||
primaryText: currentOrganisation.name,
|
|
||||||
secondaryText: _(
|
|
||||||
EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[currentOrganisation.currentOrganisationRole],
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
avatarSrc: formatAvatarUrl(user.avatarImageId),
|
|
||||||
avatarFallback: formatAvatarFallback(user.name ?? user.email),
|
|
||||||
primaryText: user.name,
|
|
||||||
secondaryText: _(msg`Personal Account`),
|
|
||||||
};
|
|
||||||
}, [currentTeam, currentOrganisation, user]);
|
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
|
||||||
if (open) {
|
|
||||||
setHoveredOrgId(currentOrganisation?.id || null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
data-testid="menu-switcher"
|
|
||||||
variant="none"
|
|
||||||
className="relative flex h-12 flex-row items-center px-0 py-2 ring-0 focus:outline-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-transparent md:px-2"
|
|
||||||
>
|
|
||||||
<AvatarWithText
|
|
||||||
avatarSrc={dropdownMenuAvatarText.avatarSrc}
|
|
||||||
avatarFallback={dropdownMenuAvatarText.avatarFallback}
|
|
||||||
primaryText={dropdownMenuAvatarText.primaryText}
|
|
||||||
secondaryText={dropdownMenuAvatarText.secondaryText}
|
|
||||||
rightSideComponent={
|
|
||||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
|
||||||
}
|
|
||||||
textSectionClassName="hidden lg:flex"
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
|
||||||
className={cn('divide-border z-[60] ml-6 flex w-full min-w-[40rem] divide-x p-0 md:ml-0')}
|
|
||||||
align="end"
|
|
||||||
forceMount
|
|
||||||
>
|
|
||||||
<div className="flex h-[400px] w-full divide-x">
|
|
||||||
{/* Organisations column */}
|
|
||||||
<div className="flex w-1/3 flex-col">
|
|
||||||
<div className="flex h-12 items-center border-b p-2">
|
|
||||||
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
|
||||||
<Building2Icon className="mr-2 h-3.5 w-3.5" />
|
|
||||||
<Trans>Organisations</Trans>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
|
|
||||||
{organisations.map((org) => (
|
|
||||||
<div
|
|
||||||
className="group relative"
|
|
||||||
key={org.id}
|
|
||||||
onMouseEnter={() => setHoveredOrgId(org.id)}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground w-full px-4 py-2',
|
|
||||||
org.id === currentOrganisation?.id && !hoveredOrgId && 'bg-accent',
|
|
||||||
org.id === hoveredOrgId && 'bg-accent',
|
|
||||||
)}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={`/org/${org.url}`} className="flex items-center space-x-2 pr-8">
|
|
||||||
<span
|
|
||||||
className={cn('min-w-0 flex-1 truncate', {
|
|
||||||
'font-semibold': org.id === selectedOrg?.id,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{org.name}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{canExecuteOrganisationAction(
|
|
||||||
'MANAGE_ORGANISATION',
|
|
||||||
org.currentOrganisationRole,
|
|
||||||
) && (
|
|
||||||
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
|
|
||||||
<Link
|
|
||||||
to={`/org/${org.url}/settings`}
|
|
||||||
className="text-muted-foreground mr-2 rounded-sm border p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Settings2Icon className="h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
|
||||||
<Link to="/settings/organisations?action=add-organisation">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Create Organisation</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Teams column */}
|
|
||||||
<div className="flex w-1/3 flex-col">
|
|
||||||
<div className="flex h-12 items-center border-b p-2">
|
|
||||||
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
|
||||||
<UsersIcon className="mr-2 h-3.5 w-3.5" />
|
|
||||||
<Trans>Teams</Trans>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-1 overflow-y-auto p-1.5">
|
|
||||||
<AnimateGenericFadeInOut key={displayedOrg ? 'displayed-org' : 'no-org'}>
|
|
||||||
{hoveredOrg ? (
|
|
||||||
hoveredOrg.teams.map((team) => (
|
|
||||||
<div className="group relative" key={team.id}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground w-full px-4 py-2',
|
|
||||||
team.id === currentTeam?.id && 'bg-accent',
|
|
||||||
)}
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={`/t/${team.url}`} className="flex items-center space-x-2 pr-8">
|
|
||||||
<span
|
|
||||||
className={cn('min-w-0 flex-1 truncate', {
|
|
||||||
'font-semibold': team.id === currentTeam?.id,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{team.name}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
|
||||||
<div className="absolute bottom-0 right-0 top-0 flex items-center justify-center">
|
|
||||||
<Link
|
|
||||||
to={`/t/${team.url}/settings`}
|
|
||||||
className="text-muted-foreground mr-2 rounded-sm border p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<Settings2Icon className="h-3.5 w-3.5" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground my-12 flex items-center justify-center px-2 text-center text-sm">
|
|
||||||
<Trans>Select an organisation to view teams</Trans>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{displayedOrg && (
|
|
||||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
|
||||||
<Link to={`/org/${displayedOrg.url}/settings/teams?action=add-team`}>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Create Team</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</AnimateGenericFadeInOut>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings column */}
|
|
||||||
<div className="flex w-1/3 flex-col">
|
|
||||||
<div className="flex h-12 items-center border-b p-2">
|
|
||||||
<h3 className="text-muted-foreground flex items-center px-2 text-sm font-medium">
|
|
||||||
<SettingsIcon className="mr-2 h-3.5 w-3.5" />
|
|
||||||
<Trans>Settings</Trans>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto p-1.5">
|
|
||||||
{isUserAdmin && (
|
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
|
||||||
<Link to="/admin">
|
|
||||||
<Trans>Admin panel</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
|
||||||
<Link to="/dashboard">
|
|
||||||
<Trans>Dashboard</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
|
||||||
<Link to="/settings/profile">
|
|
||||||
<Trans>Account</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{currentOrganisation &&
|
|
||||||
canExecuteOrganisationAction(
|
|
||||||
'MANAGE_ORGANISATION',
|
|
||||||
currentOrganisation.currentOrganisationRole,
|
|
||||||
) && (
|
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
|
||||||
<Link to={`/org/${currentOrganisation.url}/settings`}>
|
|
||||||
<Trans>Organisation settings</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{currentTeam && canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole) && (
|
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
|
||||||
<Link to={`/t/${currentTeam.url}/settings`}>
|
|
||||||
<Trans>Team settings</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-muted-foreground px-4 py-2"
|
|
||||||
onClick={() => setLanguageSwitcherOpen(true)}
|
|
||||||
>
|
|
||||||
<Trans>Language</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
|
|
||||||
onSelect={async () => authClient.signOut()}
|
|
||||||
>
|
|
||||||
<Trans>Sign Out</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
|
|
||||||
<LanguageSwitcherDialog open={languageSwitcherOpen} setOpen={setLanguageSwitcherOpen} />
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type OrganisationBillingPortalButtonProps = {
|
|
||||||
buttonProps?: React.ComponentProps<typeof Button>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrganisationBillingPortalButton = ({
|
|
||||||
buttonProps,
|
|
||||||
}: OrganisationBillingPortalButtonProps) => {
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: manageSubscription, isPending } =
|
|
||||||
trpc.billing.subscription.manage.useMutation();
|
|
||||||
|
|
||||||
const canManageBilling = canExecuteOrganisationAction(
|
|
||||||
'MANAGE_BILLING',
|
|
||||||
organisation.currentOrganisationRole,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreatePortal = async () => {
|
|
||||||
try {
|
|
||||||
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
|
|
||||||
|
|
||||||
window.open(redirectUrl, '_blank');
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(
|
|
||||||
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
|
|
||||||
),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
{...buttonProps}
|
|
||||||
onClick={async () => handleCreatePortal()}
|
|
||||||
loading={isPending}
|
|
||||||
disabled={!canManageBilling}
|
|
||||||
>
|
|
||||||
<Trans>Manage billing</Trans>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Plural, Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationMemberInviteStatus } from '@prisma/client';
|
|
||||||
import { AnimatePresence } from 'framer-motion';
|
|
||||||
import { BellIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
||||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export const OrganisationInvitations = ({ className }: { className?: string }) => {
|
|
||||||
const { data, isLoading } = trpc.organisation.member.invite.getMany.useQuery({
|
|
||||||
status: OrganisationMemberInviteStatus.PENDING,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{data && data.length > 0 && !isLoading && (
|
|
||||||
<AnimateGenericFadeInOut>
|
|
||||||
<Alert variant="secondary" className={className}>
|
|
||||||
<div className="flex h-full flex-row items-center p-2">
|
|
||||||
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
|
||||||
|
|
||||||
<AlertDescription className="mr-2">
|
|
||||||
<Plural
|
|
||||||
value={data.length}
|
|
||||||
one={
|
|
||||||
<span>
|
|
||||||
You have <strong>1</strong> pending invitation
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<span>
|
|
||||||
You have <strong>#</strong> pending invitations
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</AlertDescription>
|
|
||||||
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
|
||||||
<Trans>View invites</Trans>
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent position="center">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
<Trans>Pending invitations</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
<Plural
|
|
||||||
value={data.length}
|
|
||||||
one={
|
|
||||||
<span>
|
|
||||||
You have <strong>1</strong> pending invitation
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
other={
|
|
||||||
<span>
|
|
||||||
You have <strong>#</strong> pending invitations
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
|
||||||
{data.map((invitation) => (
|
|
||||||
<li key={invitation.id}>
|
|
||||||
<Alert variant="neutral" className="p-0 px-4">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarSrc={formatAvatarUrl(invitation.organisation.avatarImageId)}
|
|
||||||
className="w-full max-w-none py-4"
|
|
||||||
avatarFallback={invitation.organisation.name.slice(0, 1)}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">
|
|
||||||
{invitation.organisation.name}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
secondaryText={`/orgs/${invitation.organisation.url}`}
|
|
||||||
rightSideComponent={
|
|
||||||
<div className="ml-auto space-x-2">
|
|
||||||
<DeclineOrganisationInvitationButton token={invitation.token} />
|
|
||||||
<AcceptOrganisationInvitationButton token={invitation.token} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
</AnimateGenericFadeInOut>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AcceptOrganisationInvitationButton = ({ token }: { token: string }) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { refreshSession } = useSession();
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutateAsync: acceptOrganisationInvitation,
|
|
||||||
isPending,
|
|
||||||
isSuccess,
|
|
||||||
} = trpc.organisation.member.invite.accept.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refreshSession();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Invitation accepted`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`Unable to join this organisation at this time.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={async () => acceptOrganisationInvitation({ token })}
|
|
||||||
loading={isPending}
|
|
||||||
disabled={isPending || isSuccess}
|
|
||||||
>
|
|
||||||
<Trans>Accept</Trans>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const DeclineOrganisationInvitationButton = ({ token }: { token: string }) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { refreshSession } = useSession();
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutateAsync: declineOrganisationInvitation,
|
|
||||||
isPending,
|
|
||||||
isSuccess,
|
|
||||||
} = trpc.organisation.member.invite.decline.useMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refreshSession();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: _(msg`Success`),
|
|
||||||
description: _(msg`Invitation declined`),
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`Unable to decline this invitation at this time.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={async () => declineOrganisationInvitation({ token })}
|
|
||||||
loading={isPending}
|
|
||||||
disabled={isPending || isSuccess}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Trans>Decline</Trans>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
|
|
||||||
type PortalComponentProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
target: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PortalComponent = ({ children, target }: PortalComponentProps) => {
|
|
||||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPortalRoot(document.getElementById(target));
|
|
||||||
}, [target]);
|
|
||||||
|
|
||||||
return portalRoot ? createPortal(children, portalRoot) : null;
|
|
||||||
};
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Lock, User, Users } from 'lucide-react';
|
|
||||||
import { useLocation } from 'react-router';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type SettingsDesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavProps) => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
|
||||||
<Link to="/settings/profile">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/profile') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<User className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Profile</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/organisations">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Users className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Organisations</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/security">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Lock className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Security</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import type { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Lock, User, Users } from 'lucide-react';
|
|
||||||
import { Link, useLocation } from 'react-router';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type SettingsMobileNavProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProps) => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Link to="/settings/profile">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/profile') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<User className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Profile</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/organisations">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Users className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Organisations</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link to="/settings/security">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Lock className="mr-2 h-5 w-5" />
|
|
||||||
<Trans>Security</Trans>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog';
|
|
||||||
import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog';
|
|
||||||
|
|
||||||
export const AdminClaimsTable = () => {
|
|
||||||
const { t } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.admin.claims.find.useQuery({
|
|
||||||
query: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: t`ID`,
|
|
||||||
accessorKey: 'id',
|
|
||||||
maxSize: 50,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<CopyTextButton
|
|
||||||
value={row.original.id}
|
|
||||||
onCopySuccess={() => toast({ title: t`ID copied to clipboard` })}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t`Name`,
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link to={`/admin/organisations?query=claim:${row.original.id}`}>
|
|
||||||
{row.original.name}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t`Allowed teams`,
|
|
||||||
accessorKey: 'teamCount',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
if (row.original.teamCount === 0) {
|
|
||||||
return <p className="text-muted-foreground">{t`Unlimited`}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <p className="text-muted-foreground">{row.original.teamCount}</p>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t`Feature Flags`,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const flags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter(
|
|
||||||
({ key }) => row.original.flags[key],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (flags.length === 0) {
|
|
||||||
return <p className="text-muted-foreground text-xs">{t`None`}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className="text-muted-foreground list-disc space-y-1 text-xs">
|
|
||||||
{flags.map(({ key, label }) => (
|
|
||||||
<li key={key}>{label}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
<Trans>Actions</Trans>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<ClaimUpdateDialog
|
|
||||||
claim={row.original}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
|
||||||
<div>
|
|
||||||
<EditIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Update</Trans>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ClaimDeleteDialog
|
|
||||||
claimId={row.original.id}
|
|
||||||
claimName={row.original.name}
|
|
||||||
claimLocked={row.original.locked}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
|
||||||
<div>
|
|
||||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="py-4 pr-4">
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-row justify-end space-x-2">
|
|
||||||
<Skeleton className="h-2 w-6 rounded" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import {
|
|
||||||
CreditCardIcon,
|
|
||||||
ExternalLinkIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
UserIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
|
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
export const AdminOrganisationsTable = () => {
|
|
||||||
const { t, i18n } = useLingui();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.admin.organisation.find.useQuery({
|
|
||||||
query: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: t`Organisation`,
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link to={`/admin/organisations/${row.original.id}`}>{row.original.name}</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t`Created At`,
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t`Owner`,
|
|
||||||
accessorKey: 'owner',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link to={`/admin/users/${row.original.owner.id}`}>{row.original.owner.name}</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: t`Subscription`,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.subscription ? (
|
|
||||||
<Link
|
|
||||||
to={`https://dashboard.stripe.com/subscriptions/${row.original.subscription.planId}`}
|
|
||||||
target="_blank"
|
|
||||||
className="flex flex-row items-center gap-2"
|
|
||||||
>
|
|
||||||
{SUBSCRIPTION_STATUS_MAP[row.original.subscription.status]}
|
|
||||||
<ExternalLinkIcon className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
'None'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
<Trans>Actions</Trans>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to={`/admin/organisations/${row.original.id}`}>
|
|
||||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Manage</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to={`/admin/users/${row.original.owner.id}`}>
|
|
||||||
<UserIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>View owner</Trans>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!row.original.customerId} asChild>
|
|
||||||
<Link to={`https://dashboard.stripe.com/customers/${row.original.customerId}`}>
|
|
||||||
<CreditCardIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Stripe</Trans>
|
|
||||||
{!row.original.customerId && <span> (N/A)</span>}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="py-4 pr-4">
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-row justify-end space-x-2">
|
|
||||||
<Skeleton className="h-2 w-6 rounded" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
import { useMemo, useTransition } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Loader } from 'lucide-react';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
|
||||||
import { DocumentsTableActionButton } from './documents-table-action-button';
|
|
||||||
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
|
|
||||||
|
|
||||||
export type DocumentsTableProps = {
|
|
||||||
data?: TFindDocumentsResponse;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isLoadingError?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
|
||||||
|
|
||||||
export const DocumentsTable = ({ data, isLoading, isLoadingError }: DocumentsTableProps) => {
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: _(msg`Created`),
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Title`),
|
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sender',
|
|
||||||
header: _(msg`Sender`),
|
|
||||||
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Recipient`),
|
|
||||||
accessorKey: 'recipient',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<StackAvatarsWithTooltip
|
|
||||||
recipients={row.original.recipients}
|
|
||||||
documentStatus={row.original.status}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Status`),
|
|
||||||
accessorKey: 'status',
|
|
||||||
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
|
|
||||||
size: 140,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Actions`),
|
|
||||||
cell: ({ row }) =>
|
|
||||||
(!row.original.deletedAt || isDocumentCompleted(row.original.status)) && (
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<DocumentsTableActionButton row={row.original} />
|
|
||||||
<DocumentsTableActionDropdown row={row.original} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
|
||||||
}, [team]);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
startTransition(() => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
columnVisibility={{
|
|
||||||
sender: team !== undefined,
|
|
||||||
}}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError || false,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading || false,
|
|
||||||
rows: 5,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-40 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-10 w-24 rounded" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
|
|
||||||
{isPending && (
|
|
||||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
|
||||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type DataTableTitleProps = {
|
|
||||||
row: DocumentsTableRow;
|
|
||||||
teamUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
|
||||||
const { user } = useSession();
|
|
||||||
|
|
||||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
|
||||||
|
|
||||||
const isOwner = row.user.id === user.id;
|
|
||||||
const isRecipient = !!recipient;
|
|
||||||
const isCurrentTeamDocument = teamUrl && row.team?.url === teamUrl;
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(isCurrentTeamDocument ? teamUrl : undefined);
|
|
||||||
|
|
||||||
return match({
|
|
||||||
isOwner,
|
|
||||||
isRecipient,
|
|
||||||
isCurrentTeamDocument,
|
|
||||||
})
|
|
||||||
.with({ isOwner: true }, { isCurrentTeamDocument: true }, () => (
|
|
||||||
<Link
|
|
||||||
to={`${documentsPath}/${row.id}`}
|
|
||||||
title={row.title}
|
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
|
||||||
>
|
|
||||||
{row.title}
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
.with({ isRecipient: true }, () => (
|
|
||||||
<Link
|
|
||||||
to={`/sign/${recipient?.token}`}
|
|
||||||
title={row.title}
|
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
|
||||||
>
|
|
||||||
{row.title}
|
|
||||||
</Link>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<span className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]">
|
|
||||||
{row.title}
|
|
||||||
</span>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
import { useMemo, useTransition } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { RecipientRole } from '@prisma/client';
|
|
||||||
import { CheckCircleIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
|
||||||
|
|
||||||
export type DocumentsTableProps = {
|
|
||||||
data?: TFindDocumentsResponse;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isLoadingError?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
|
|
||||||
|
|
||||||
export const InboxTable = () => {
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
|
|
||||||
const team = useOptionalCurrentTeam();
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
|
|
||||||
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery({
|
|
||||||
status: ExtendedDocumentStatus.INBOX,
|
|
||||||
page: page || 1,
|
|
||||||
perPage: perPage || 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: _(msg`Created`),
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Title`),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link
|
|
||||||
to={`/sign/${row.original.recipients[0]?.token}`}
|
|
||||||
title={row.original.title}
|
|
||||||
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
|
|
||||||
>
|
|
||||||
{row.original.title}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sender',
|
|
||||||
header: _(msg`Sender`),
|
|
||||||
cell: ({ row }) => row.original.user.name ?? row.original.user.email,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Recipient`),
|
|
||||||
accessorKey: 'recipient',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<StackAvatarsWithTooltip
|
|
||||||
recipients={row.original.recipients}
|
|
||||||
documentStatus={row.original.status}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Actions`),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-x-4">
|
|
||||||
<Button className="w-32" asChild>
|
|
||||||
<Link to={`/sign/${row.original.recipients[0]?.token}`}>
|
|
||||||
{match(row.original.recipients[0]?.role)
|
|
||||||
.with(RecipientRole.SIGNER, () => (
|
|
||||||
<>
|
|
||||||
<PencilIcon className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
<Trans>Sign</Trans>
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.with(RecipientRole.APPROVER, () => (
|
|
||||||
<>
|
|
||||||
<CheckCircleIcon className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
<Trans>Approve</Trans>
|
|
||||||
</>
|
|
||||||
))
|
|
||||||
.otherwise(() => (
|
|
||||||
<>
|
|
||||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
|
||||||
<Trans>View</Trans>
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<DocumentsTableRow>[];
|
|
||||||
}, [team]);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
startTransition(() => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
columnVisibility={{
|
|
||||||
sender: team !== undefined,
|
|
||||||
}}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError || false,
|
|
||||||
}}
|
|
||||||
emptyState={
|
|
||||||
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
|
||||||
<p>
|
|
||||||
<Trans>Documents that require your attention will appear here</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading || false,
|
|
||||||
rows: 5,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-40 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="py-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-10 w-24 rounded" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) =>
|
|
||||||
results.totalPages > 1 && (
|
|
||||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</DataTable>
|
|
||||||
|
|
||||||
{isPending && (
|
|
||||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
|
|
||||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationGroupType } from '@prisma/client';
|
|
||||||
import { Link, useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { OrganisationGroupDeleteDialog } from '../dialogs/organisation-group-delete-dialog';
|
|
||||||
|
|
||||||
export const OrganisationGroupsDataTable = () => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.organisation.group.find.useQuery(
|
|
||||||
{
|
|
||||||
organisationId: organisation.id,
|
|
||||||
query: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
types: [OrganisationGroupType.CUSTOM],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: _(msg`Group`),
|
|
||||||
accessorKey: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Role`),
|
|
||||||
accessorKey: 'organisationRole',
|
|
||||||
cell: ({ row }) => _(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[row.original.organisationRole]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Members`),
|
|
||||||
accessorKey: 'members',
|
|
||||||
cell: ({ row }) => row.original.members.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Assigned Teams`),
|
|
||||||
accessorKey: 'teams',
|
|
||||||
cell: ({ row }) => row.original.teams.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Actions`),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link to={`/org/${organisation.url}/settings/groups/${row.original.id}`}>Manage</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<OrganisationGroupDeleteDialog
|
|
||||||
organisationGroupId={row.original.id}
|
|
||||||
organisationGroupName={row.original.name ?? ''}
|
|
||||||
trigger={
|
|
||||||
<Button variant="destructive" title={_(msg`Remove organisation group`)}>
|
|
||||||
<Trans>Delete</Trans>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/2 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-6 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) =>
|
|
||||||
results.totalPages > 1 && (
|
|
||||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationGroupType } from '@prisma/client';
|
|
||||||
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
||||||
import { useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
|
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { OrganisationMemberDeleteDialog } from '~/components/dialogs/organisation-member-delete-dialog';
|
|
||||||
import { OrganisationMemberUpdateDialog } from '~/components/dialogs/organisation-member-update-dialog';
|
|
||||||
|
|
||||||
export const OrganisationMembersDataTable = () => {
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
const organisation = useCurrentOrganisation();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.organisation.member.find.useQuery(
|
|
||||||
{
|
|
||||||
organisationId: organisation.id,
|
|
||||||
query: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: _(msg`Organisation Member`),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const avatarFallbackText = row.original.name
|
|
||||||
? extractInitials(row.original.name)
|
|
||||||
: row.original.email.slice(0, 1).toUpperCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={avatarFallbackText}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
|
||||||
}
|
|
||||||
secondaryText={row.original.email}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Role`),
|
|
||||||
accessorKey: 'role',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
organisation.ownerUserId === row.original.userId
|
|
||||||
? _(msg`Owner`)
|
|
||||||
: _(ORGANISATION_MEMBER_ROLE_MAP[row.original.currentOrganisationRole]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Member Since`),
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Groups`),
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.groups.filter((group) => group.type === OrganisationGroupType.CUSTOM).length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Actions`),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
<Trans>Actions</Trans>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<OrganisationMemberUpdateDialog
|
|
||||||
currentUserOrganisationRole={organisation.currentOrganisationRole}
|
|
||||||
organisationId={organisation.id}
|
|
||||||
organisationMemberId={row.original.id}
|
|
||||||
organisationMemberName={row.original.name ?? ''}
|
|
||||||
organisationMemberRole={row.original.currentOrganisationRole}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={
|
|
||||||
organisation.ownerUserId === row.original.userId ||
|
|
||||||
!isOrganisationRoleWithinUserHierarchy(
|
|
||||||
organisation.currentOrganisationRole,
|
|
||||||
row.original.currentOrganisationRole,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
title="Update organisation member role"
|
|
||||||
>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Update role</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OrganisationMemberDeleteDialog
|
|
||||||
organisationMemberId={row.original.id}
|
|
||||||
organisationMemberName={row.original.name ?? ''}
|
|
||||||
organisationMemberEmail={row.original.email}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
disabled={
|
|
||||||
organisation.ownerUserId === row.original.userId ||
|
|
||||||
!isOrganisationRoleWithinUserHierarchy(
|
|
||||||
organisation.currentOrganisationRole,
|
|
||||||
row.original.currentOrganisationRole,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={_(msg`Remove organisation member`)}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Remove</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/2 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-6 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) =>
|
|
||||||
results.totalPages > 1 && (
|
|
||||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { OrganisationGroupType } from '@prisma/client';
|
|
||||||
import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
|
||||||
import { useSearchParams } from 'react-router';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
|
||||||
|
|
||||||
import { TeamGroupDeleteDialog } from '../dialogs/team-group-delete-dialog';
|
|
||||||
import { TeamGroupUpdateDialog } from '../dialogs/team-group-update-dialog';
|
|
||||||
|
|
||||||
export const TeamGroupsTable = () => {
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
const team = useCurrentTeam();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.team.group.find.useQuery(
|
|
||||||
{
|
|
||||||
teamId: team.id,
|
|
||||||
query: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
types: [OrganisationGroupType.CUSTOM],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: _(msg`Group`),
|
|
||||||
accessorKey: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Role`),
|
|
||||||
accessorKey: 'teamRole',
|
|
||||||
cell: ({ row }) => _(EXTENDED_TEAM_MEMBER_ROLE_MAP[row.original.teamRole]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Members`),
|
|
||||||
accessorKey: 'members',
|
|
||||||
cell: ({ row }) => row.original.members.length,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Actions`),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
<Trans>Actions</Trans>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<TeamGroupUpdateDialog
|
|
||||||
teamGroupId={row.original.id}
|
|
||||||
teamGroupName={row.original.name ?? ''}
|
|
||||||
teamGroupRole={row.original.teamRole}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
title="Update team group role"
|
|
||||||
>
|
|
||||||
<EditIcon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Update role</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TeamGroupDeleteDialog
|
|
||||||
teamGroupId={row.original.id}
|
|
||||||
teamGroupName={row.original.name ?? ''}
|
|
||||||
teamGroupRole={row.original.teamRole}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
|
||||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Remove</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
emptyState={
|
|
||||||
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
|
|
||||||
<p>
|
|
||||||
<Trans>No team groups found</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/2 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-6 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) =>
|
|
||||||
results.totalPages > 1 && (
|
|
||||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
|
||||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
|
||||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { OrganisationLeaveDialog } from '../dialogs/organisation-leave-dialog';
|
|
||||||
|
|
||||||
export const UserOrganisationsTable = () => {
|
|
||||||
const { _, i18n } = useLingui();
|
|
||||||
const { user } = useSession();
|
|
||||||
|
|
||||||
const { data, isLoading, isLoadingError } = trpc.organisation.getMany.useQuery();
|
|
||||||
|
|
||||||
const results = {
|
|
||||||
data: data || [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
header: _(msg`Organisation`),
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link to={`/org/${row.original.url}`} preventScrollReset={true}>
|
|
||||||
<AvatarWithText
|
|
||||||
avatarSrc={formatAvatarUrl(row.original.avatarImageId)}
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
|
||||||
}
|
|
||||||
secondaryText={`${NEXT_PUBLIC_WEBAPP_URL()}/org/${row.original.url}`}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Role`),
|
|
||||||
accessorKey: 'role',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.ownerUserId === user.id
|
|
||||||
? _(msg`Owner`)
|
|
||||||
: _(ORGANISATION_MEMBER_ROLE_MAP[row.original.currentOrganisationRole]),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: _(msg`Member Since`),
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => i18n.date(row.original.createdAt),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
{canExecuteOrganisationAction(
|
|
||||||
'MANAGE_ORGANISATION',
|
|
||||||
row.original.currentOrganisationRole,
|
|
||||||
) && (
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to={`/org/${row.original.url}/settings`}>
|
|
||||||
<Trans>Manage</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<OrganisationLeaveDialog
|
|
||||||
organisationId={row.original.id}
|
|
||||||
organisationName={row.original.name}
|
|
||||||
organisationAvatarImageId={row.original.avatarImageId}
|
|
||||||
role={row.original.currentOrganisationRole}
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={row.original.ownerUserId === user.id}
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Trans>Leave</Trans>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/3 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-row justify-end space-x-2">
|
|
||||||
<Skeleton className="h-10 w-20 rounded" />
|
|
||||||
<Skeleton className="h-10 w-16 rounded" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { StrictMode, startTransition, useEffect } from 'react';
|
|
||||||
|
|
||||||
import { i18n } from '@lingui/core';
|
|
||||||
import { detect, fromHtmlTag } from '@lingui/detect-locale';
|
|
||||||
import { I18nProvider } from '@lingui/react';
|
|
||||||
import posthog from 'posthog-js';
|
|
||||||
import { hydrateRoot } from 'react-dom/client';
|
|
||||||
import { HydratedRouter } from 'react-router/dom';
|
|
||||||
|
|
||||||
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
|
|
||||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
|
||||||
|
|
||||||
function PosthogInit() {
|
|
||||||
const postHogConfig = extractPostHogConfig();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (postHogConfig) {
|
|
||||||
posthog.init(postHogConfig.key, {
|
|
||||||
api_host: postHogConfig.host,
|
|
||||||
capture_exceptions: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const locale = detect(fromHtmlTag('lang')) || 'en';
|
|
||||||
|
|
||||||
await dynamicActivate(locale);
|
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
hydrateRoot(
|
|
||||||
document,
|
|
||||||
<StrictMode>
|
|
||||||
<I18nProvider i18n={i18n}>
|
|
||||||
<HydratedRouter />
|
|
||||||
</I18nProvider>
|
|
||||||
|
|
||||||
<PosthogInit />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
main();
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import { i18n } from '@lingui/core';
|
|
||||||
import { I18nProvider } from '@lingui/react';
|
|
||||||
import { createReadableStreamFromReadable } from '@react-router/node';
|
|
||||||
import { isbot } from 'isbot';
|
|
||||||
import { PassThrough } from 'node:stream';
|
|
||||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
|
||||||
import { renderToPipeableStream } from 'react-dom/server';
|
|
||||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
|
||||||
import { ServerRouter } from 'react-router';
|
|
||||||
|
|
||||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
|
||||||
import { dynamicActivate, extractLocaleData } from '@documenso/lib/utils/i18n';
|
|
||||||
|
|
||||||
import { langCookie } from './storage/lang-cookie.server';
|
|
||||||
|
|
||||||
export const streamTimeout = 5_000;
|
|
||||||
|
|
||||||
export default async function handleRequest(
|
|
||||||
request: Request,
|
|
||||||
responseStatusCode: number,
|
|
||||||
responseHeaders: Headers,
|
|
||||||
routerContext: EntryContext,
|
|
||||||
_loadContext: AppLoadContext,
|
|
||||||
) {
|
|
||||||
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
|
|
||||||
|
|
||||||
if (!APP_I18N_OPTIONS.supportedLangs.includes(language)) {
|
|
||||||
language = extractLocaleData({ headers: request.headers }).lang;
|
|
||||||
}
|
|
||||||
|
|
||||||
await dynamicActivate(language);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let shellRendered = false;
|
|
||||||
const userAgent = request.headers.get('user-agent');
|
|
||||||
|
|
||||||
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
|
||||||
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
|
||||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
|
||||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
|
|
||||||
|
|
||||||
const { pipe, abort } = renderToPipeableStream(
|
|
||||||
<I18nProvider i18n={i18n}>
|
|
||||||
<ServerRouter context={routerContext} url={request.url} />
|
|
||||||
</I18nProvider>,
|
|
||||||
{
|
|
||||||
[readyOption]() {
|
|
||||||
shellRendered = true;
|
|
||||||
const body = new PassThrough();
|
|
||||||
const stream = createReadableStreamFromReadable(body);
|
|
||||||
|
|
||||||
responseHeaders.set('Content-Type', 'text/html');
|
|
||||||
|
|
||||||
resolve(
|
|
||||||
new Response(stream, {
|
|
||||||
headers: responseHeaders,
|
|
||||||
status: responseStatusCode,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
pipe(body);
|
|
||||||
},
|
|
||||||
onShellError(error: unknown) {
|
|
||||||
reject(error);
|
|
||||||
},
|
|
||||||
onError(error: unknown) {
|
|
||||||
responseStatusCode = 500;
|
|
||||||
// Log streaming rendering errors from inside the shell. Don't log
|
|
||||||
// errors encountered during initial shell rendering since they'll
|
|
||||||
// reject and get logged in handleDocumentRequest.
|
|
||||||
if (shellRendered) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Abort the rendering stream after the `streamTimeout` so it has time to
|
|
||||||
// flush down the rejected boundaries
|
|
||||||
setTimeout(abort, streamTimeout + 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import Plausible from 'plausible-tracker';
|
|
||||||
import {
|
|
||||||
Links,
|
|
||||||
Meta,
|
|
||||||
Outlet,
|
|
||||||
Scripts,
|
|
||||||
ScrollRestoration,
|
|
||||||
data,
|
|
||||||
isRouteErrorResponse,
|
|
||||||
useLoaderData,
|
|
||||||
useLocation,
|
|
||||||
} from 'react-router';
|
|
||||||
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
|
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { APP_I18N_OPTIONS, type SupportedLanguageCodes } from '@documenso/lib/constants/i18n';
|
|
||||||
import { createPublicEnv, env } from '@documenso/lib/utils/env';
|
|
||||||
import { extractLocaleData } from '@documenso/lib/utils/i18n';
|
|
||||||
import { TrpcProvider } from '@documenso/trpc/react';
|
|
||||||
import { getOrganisationSession } from '@documenso/trpc/server/organisation-router/get-organisation-session';
|
|
||||||
import { Toaster } from '@documenso/ui/primitives/toaster';
|
|
||||||
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
|
|
||||||
|
|
||||||
import type { Route } from './+types/root';
|
|
||||||
import stylesheet from './app.css?url';
|
|
||||||
import { GenericErrorLayout } from './components/general/generic-error-layout';
|
|
||||||
import { langCookie } from './storage/lang-cookie.server';
|
|
||||||
import { themeSessionResolver } from './storage/theme-session.server';
|
|
||||||
import { appMetaTags } from './utils/meta';
|
|
||||||
|
|
||||||
const { trackPageview } = Plausible({
|
|
||||||
domain: 'documenso.com',
|
|
||||||
trackLocalhost: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
|
||||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
|
||||||
{
|
|
||||||
rel: 'preconnect',
|
|
||||||
href: 'https://fonts.gstatic.com',
|
|
||||||
crossOrigin: 'anonymous',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: 'stylesheet',
|
|
||||||
href: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400..600&display=swap',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rel: 'stylesheet',
|
|
||||||
href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
|
|
||||||
},
|
|
||||||
{ rel: 'stylesheet', href: stylesheet },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function meta() {
|
|
||||||
return appMetaTags();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Don't revalidate (run the loader on sequential navigations) on the root layout
|
|
||||||
*
|
|
||||||
* Update values via providers.
|
|
||||||
*/
|
|
||||||
export const shouldRevalidate = () => false;
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
|
||||||
const session = await getOptionalSession(request);
|
|
||||||
|
|
||||||
const { getTheme } = await themeSessionResolver(request);
|
|
||||||
|
|
||||||
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
|
|
||||||
|
|
||||||
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
|
|
||||||
lang = extractLocaleData({ headers: request.headers }).lang;
|
|
||||||
}
|
|
||||||
|
|
||||||
let organisations = null;
|
|
||||||
|
|
||||||
if (session.isAuthenticated) {
|
|
||||||
organisations = await getOrganisationSession({ userId: session.user.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
return data(
|
|
||||||
{
|
|
||||||
lang,
|
|
||||||
theme: getTheme(),
|
|
||||||
session: session.isAuthenticated
|
|
||||||
? {
|
|
||||||
user: session.user,
|
|
||||||
session: session.session,
|
|
||||||
organisations: organisations || [],
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
publicEnv: createPublicEnv(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Set-Cookie': await langCookie.serialize(lang),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
|
||||||
const { theme } = useLoaderData<typeof loader>() || {};
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (env('NODE_ENV') === 'production') {
|
|
||||||
trackPageview();
|
|
||||||
}
|
|
||||||
}, [location.pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider specifiedTheme={theme} themeAction="/api/theme">
|
|
||||||
<LayoutContent>{children}</LayoutContent>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LayoutContent({ children }: { children: React.ReactNode }) {
|
|
||||||
const { publicEnv, session, lang, ...data } = useLoaderData<typeof loader>() || {};
|
|
||||||
|
|
||||||
const [theme] = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<html translate="no" lang={lang} data-theme={theme} className={theme ?? ''}>
|
|
||||||
<head>
|
|
||||||
<meta charSet="utf-8" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
|
||||||
<meta name="google" content="notranslate" />
|
|
||||||
<Meta />
|
|
||||||
<Links />
|
|
||||||
<meta name="google" content="notranslate" />
|
|
||||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
|
||||||
|
|
||||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
|
||||||
<script>0</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<SessionProvider initialSession={session}>
|
|
||||||
<TooltipProvider>
|
|
||||||
<TrpcProvider>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
<Toaster />
|
|
||||||
</TrpcProvider>
|
|
||||||
</TooltipProvider>
|
|
||||||
</SessionProvider>
|
|
||||||
|
|
||||||
<ScrollRestoration />
|
|
||||||
<Scripts />
|
|
||||||
|
|
||||||
<script
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|
||||||
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
|
|
||||||
|
|
||||||
if (errorCode !== 404) {
|
|
||||||
console.error('[RootErrorBoundary]', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <GenericErrorLayout errorCode={errorCode} />;
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { remixRoutesOptionAdapter } from '@react-router/remix-routes-option-adapter';
|
|
||||||
import { flatRoutes } from 'remix-flat-routes';
|
|
||||||
|
|
||||||
export default remixRoutesOptionAdapter((defineRoutes) => {
|
|
||||||
return flatRoutes('routes', defineRoutes, {
|
|
||||||
ignoredRouteFiles: ['**/.*'], // Ignore dot files (like .DS_Store)
|
|
||||||
//appDir: 'app',
|
|
||||||
//routeDir: 'routes',
|
|
||||||
//basePath: '/',
|
|
||||||
//paramPrefixChar: '$',
|
|
||||||
//routeRegex: /(([+][\/\\][^\/\\:?*]+)|[\/\\]((index|route|layout|page)|(_[^\/\\:?*]+)|([^\/\\:?*]+\.route)))\.(ts|tsx|js|jsx|md|mdx)$$/,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Link, Outlet, redirect } from 'react-router';
|
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
|
||||||
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { Header } from '~/components/general/app-header';
|
|
||||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
|
||||||
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
|
|
||||||
import { TeamProvider } from '~/providers/team';
|
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Don't revalidate (run the loader on sequential navigations)
|
|
||||||
*
|
|
||||||
* Update values via providers.
|
|
||||||
*/
|
|
||||||
export const shouldRevalidate = () => false;
|
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
|
||||||
const requestHeaders = Object.fromEntries(request.headers.entries());
|
|
||||||
|
|
||||||
const session = await getOptionalSession(request);
|
|
||||||
|
|
||||||
if (!session.isAuthenticated) {
|
|
||||||
throw redirect('/signin');
|
|
||||||
}
|
|
||||||
|
|
||||||
// const [limits, banner] = await Promise.all([
|
|
||||||
// getLimits({ headers: requestHeaders }),
|
|
||||||
// getSiteSettings().then((settings) =>
|
|
||||||
// settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
|
||||||
// ),
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// banner,
|
|
||||||
// limits,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Layout({ loaderData, params }: Route.ComponentProps) {
|
|
||||||
const { user, organisations } = useSession();
|
|
||||||
|
|
||||||
// const { banner, limits } = loaderData;
|
|
||||||
|
|
||||||
const teamUrl = params.teamUrl;
|
|
||||||
const orgUrl = params.orgUrl;
|
|
||||||
|
|
||||||
const teams = organisations.flatMap((org) => org.teams);
|
|
||||||
|
|
||||||
// Todo: orgs limits
|
|
||||||
// const limits = useMemo(() => {
|
|
||||||
// if (!currentTeam) {
|
|
||||||
// return undefined;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (
|
|
||||||
// currentTeam?.subscription &&
|
|
||||||
// currentTeam.subscription.status === SubscriptionStatus.INACTIVE
|
|
||||||
// ) {
|
|
||||||
// return {
|
|
||||||
// quota: {
|
|
||||||
// documents: 0,
|
|
||||||
// recipients: 0,
|
|
||||||
// directTemplates: 0,
|
|
||||||
// },
|
|
||||||
// remaining: {
|
|
||||||
// documents: 0,
|
|
||||||
// recipients: 0,
|
|
||||||
// directTemplates: 0,
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// quota: TEAM_PLAN_LIMITS,
|
|
||||||
// remaining: TEAM_PLAN_LIMITS,
|
|
||||||
// };
|
|
||||||
// }, [currentTeam?.subscription, currentTeam?.id]);
|
|
||||||
|
|
||||||
const extractCurrentOrganisation = () => {
|
|
||||||
if (orgUrl) {
|
|
||||||
return organisations.find((org) => org.url === orgUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search organisations to find the team since we don't have access to the orgUrl in the URL.
|
|
||||||
if (teamUrl) {
|
|
||||||
return organisations.find((org) => org.teams.some((team) => team.url === teamUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentTeam = teams.find((team) => team.url === teamUrl);
|
|
||||||
const currentOrganisation = extractCurrentOrganisation() || null;
|
|
||||||
|
|
||||||
const orgNotFound = params.orgUrl && !currentOrganisation;
|
|
||||||
const teamNotFound = params.teamUrl && !currentTeam;
|
|
||||||
|
|
||||||
if (orgNotFound || teamNotFound) {
|
|
||||||
return (
|
|
||||||
<GenericErrorLayout
|
|
||||||
errorCode={404}
|
|
||||||
errorCodeMap={{
|
|
||||||
404: orgNotFound
|
|
||||||
? {
|
|
||||||
heading: msg`Organisation not found`,
|
|
||||||
subHeading: msg`404 Organisation not found`,
|
|
||||||
message: msg`The organisation you are looking for may have been removed, renamed or may have never
|
|
||||||
existed.`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
heading: msg`Team not found`,
|
|
||||||
subHeading: msg`404 Team not found`,
|
|
||||||
message: msg`The team you are looking for may have been removed, renamed or may have never
|
|
||||||
existed.`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
primaryButton={
|
|
||||||
<Button asChild>
|
|
||||||
<Link to="/">
|
|
||||||
<Trans>Go home</Trans>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OrganisationProvider organisation={currentOrganisation}>
|
|
||||||
<TeamProvider team={currentTeam || null}>
|
|
||||||
<LimitsProvider>
|
|
||||||
<div id="portal-header"></div>
|
|
||||||
|
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
|
||||||
|
|
||||||
{/* {banner && <AppBanner banner={banner} />} */}
|
|
||||||
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</LimitsProvider>
|
|
||||||
</TeamProvider>
|
|
||||||
</OrganisationProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { redirect } from 'react-router';
|
|
||||||
|
|
||||||
export function loader() {
|
|
||||||
throw redirect('/admin/stats');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
|
||||||
// Redirect page.
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user