mirror of
https://github.com/documenso/documenso.git
synced 2025-11-25 06:01:35 +10:00
Compare commits
29 Commits
feat/organ
...
feat/dicta
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a6942f9da | |||
| 8b82d22f9f | |||
| 00e402f4cb | |||
| 1e90ca45a6 | |||
| 4189a34de0 | |||
| 2ff330f9d4 | |||
| ce1c93b2a6 | |||
| 82337e4e3a | |||
| 7d9a3f9776 | |||
| cbad065dac | |||
| 25a3861c91 | |||
| b9ae277041 | |||
| 7fad826d06 | |||
| eb8ba2036a | |||
| 339759166c | |||
| 637e06f9c0 | |||
| 332e0657e0 | |||
| 4017b250fb | |||
| 41373a7c6f | |||
| 7cc85ca6bc | |||
| bc19fa0cbd | |||
| a60f58e20b | |||
| aca902b5ff | |||
| 2f866c41b4 | |||
| 7e4faef95f | |||
| bcef84787d | |||
| 70a3ac0525 | |||
| c6fb101a99 | |||
| 2984af769c |
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:
|
||||
|
||||
- Write concise, technical TypeScript code with accurate examples
|
||||
- Use functional and declarative programming patterns; avoid classes
|
||||
- Prefer iteration and modularization over code duplication
|
||||
@ -9,25 +6,20 @@ Code Style and Structure:
|
||||
- Structure files: exported component, subcomponents, helpers, static content, types
|
||||
|
||||
Naming Conventions:
|
||||
|
||||
- Use lowercase with dashes for directories (e.g., components/auth-wizard)
|
||||
- Favor named exports for components
|
||||
|
||||
TypeScript Usage:
|
||||
|
||||
- Use TypeScript for all code; prefer types over interfaces
|
||||
- Use TypeScript for all code; prefer interfaces over types
|
||||
- Avoid enums; use maps instead
|
||||
- Use functional components with TypeScript interfaces
|
||||
|
||||
Syntax and Formatting:
|
||||
|
||||
- Create functions using `const fn = () => {}`
|
||||
- Use the "function" keyword for pure functions
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements
|
||||
- Use declarative JSX
|
||||
- Never use 'use client'
|
||||
- Never use 1 line if statements
|
||||
|
||||
Error Handling and Validation:
|
||||
|
||||
- Prioritize error handling: handle errors and edge cases early
|
||||
- Use early returns and guard clauses
|
||||
- Implement proper error logging and user-friendly messages
|
||||
@ -36,40 +28,21 @@ Error Handling and Validation:
|
||||
- Use error boundaries for unexpected errors
|
||||
|
||||
UI and Styling:
|
||||
|
||||
- Use Shadcn UI, Radix, and Tailwind Aria for components and styling
|
||||
- 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
|
||||
- Look at TeamCreateDialog.tsx as an example of form usage
|
||||
- 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
|
||||
Key Conventions:
|
||||
- Use 'nuqs' for URL search parameter state management
|
||||
- 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
|
||||
|
||||
- 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
|
||||
Follow Next.js docs for Data Fetching, Rendering, and Routing
|
||||
14
.env.example
14
.env.example
@ -1,4 +1,5 @@
|
||||
# [[AUTH]]
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# [[CRYPTO]]
|
||||
@ -18,10 +19,14 @@ NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||
NEXT_PRIVATE_OIDC_CLIENT_SECRET=""
|
||||
NEXT_PRIVATE_OIDC_PROVIDER_LABEL="OIDC"
|
||||
# This can be used to still allow signups for OIDC connections
|
||||
# when signup is disabled via `NEXT_PUBLIC_DISABLE_SIGNUP`
|
||||
NEXT_PRIVATE_OIDC_ALLOW_SIGNUP=""
|
||||
NEXT_PRIVATE_OIDC_SKIP_VERIFY=""
|
||||
|
||||
# [[URLS]]
|
||||
NEXT_PUBLIC_WEBAPP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_MARKETING_URL="http://localhost:3001"
|
||||
# URL used by the web app to request itself (e.g. local background jobs)
|
||||
NEXT_PRIVATE_INTERNAL_WEBAPP_URL="http://localhost:3000"
|
||||
|
||||
@ -108,9 +113,13 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
||||
# [[STRIPE]]
|
||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
|
||||
|
||||
# [[BACKGROUND JOBS]]
|
||||
NEXT_PRIVATE_JOBS_PROVIDER="local"
|
||||
NEXT_PRIVATE_TRIGGER_API_KEY=
|
||||
NEXT_PRIVATE_TRIGGER_API_URL=
|
||||
NEXT_PRIVATE_INNGEST_EVENT_KEY=
|
||||
|
||||
# [[FEATURES]]
|
||||
@ -126,5 +135,10 @@ E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
|
||||
# This is only required for the marketing site
|
||||
# [[REDIS]]
|
||||
NEXT_PRIVATE_REDIS_URL=
|
||||
NEXT_PRIVATE_REDIS_TOKEN=
|
||||
|
||||
# [[LOGGER]]
|
||||
NEXT_PRIVATE_LOGGER_HONEY_BADGER_API_KEY=
|
||||
|
||||
@ -5,7 +5,6 @@ module.exports = {
|
||||
rules: {
|
||||
'@next/next/no-img-element': 'off',
|
||||
'no-unreachable': 'error',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
settings: {
|
||||
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:
|
||||
node_version:
|
||||
required: false
|
||||
default: v22.x
|
||||
default: v20.x
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -26,8 +26,7 @@ jobs:
|
||||
- name: Copy env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
- uses: ./.github/actions/cache-build
|
||||
|
||||
build_docker:
|
||||
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
|
||||
5
.github/workflows/codeql-analysis.yml
vendored
5
.github/workflows/codeql-analysis.yml
vendored
@ -10,7 +10,7 @@ on:
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@ -30,8 +30,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/node-install
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
- uses: ./.github/actions/cache-build
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
||||
5
.github/workflows/e2e-tests.yml
vendored
5
.github/workflows/e2e-tests.yml
vendored
@ -1,7 +1,7 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
push:
|
||||
branches: ['main', 'feat/rr7']
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
jobs:
|
||||
@ -28,8 +28,7 @@ jobs:
|
||||
- name: Seed the database
|
||||
run: npm run prisma:seed
|
||||
|
||||
- name: Build app
|
||||
run: npm run build
|
||||
- uses: ./.github/actions/cache-build
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: npm run ci
|
||||
|
||||
@ -4,7 +4,9 @@ tasks:
|
||||
npm run dx:up &&
|
||||
cp .env.example .env &&
|
||||
set -a; source .env &&
|
||||
export NEXTAUTH_URL="$(gp url 3000)" &&
|
||||
export NEXT_PUBLIC_WEBAPP_URL="$(gp url 3000)" &&
|
||||
export NEXT_PUBLIC_MARKETING_URL="$(gp url 3001)"
|
||||
command: npm run d
|
||||
|
||||
ports:
|
||||
|
||||
@ -4,9 +4,12 @@
|
||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
|
||||
|
||||
echo "Copying pdf.js"
|
||||
npm run copy:pdfjs --workspace apps/**
|
||||
|
||||
echo "Copying .well-known/ contents"
|
||||
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
|
||||
|
||||
git add "$MONOREPO_ROOT/apps/remix/public/"
|
||||
git add "$MONOREPO_ROOT/apps/web/public/"
|
||||
|
||||
npx lint-staged
|
||||
|
||||
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">
|
||||
|
||||
<p align="center" style="margin-top: 20px">
|
||||
@ -69,9 +73,9 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
<a href="https://cal.com/timurercan/enterprise-customers?utm_source=banner&utm_campaign=oss"><img alt="Book us with Cal.com" src="https://cal.com/book-with-cal-dark.svg" /></a>
|
||||
|
||||
## Tech Stack
|
||||
|
||||
<p align="left">
|
||||
<a href="https://www.typescriptlang.org"><img src="https://shields.io/badge/TypeScript-3178C6?logo=TypeScript&logoColor=FFF&style=flat-square" alt="TypeScript"></a>
|
||||
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/next.js-000000?style=flat-square&logo=nextdotjs&logoColor=white" alt="NextJS"></a>
|
||||
<a href="https://prisma.io"><img width="122" height="20" src="http://made-with.prisma.io/indigo.svg" alt="Made with Prisma" /></a>
|
||||
<a href="https://tailwindcss.com/"><img src="https://img.shields.io/badge/tailwindcss-0F172A?&logo=tailwindcss" alt="Tailwind CSS"></a>
|
||||
<a href=""><img src="" alt=""></a>
|
||||
@ -81,17 +85,20 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
<a href=""><img src="" alt=""></a>
|
||||
</p>
|
||||
|
||||
|
||||
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||
- [ReactRouter](https://reactrouter.com/) - Framework
|
||||
- [Prisma](https://www.prisma.io/) - ORM
|
||||
- [Next.js](https://nextjs.org/) - Framework
|
||||
- [Prisma](https://www.prisma.io/) - ORM
|
||||
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
||||
- [react-email](https://react.email/) - Email Templates
|
||||
- [tRPC](https://trpc.io/) - API
|
||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures (launching soon)
|
||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||
- [Stripe](https://stripe.com/) - Payments
|
||||
- [Vercel](https://vercel.com) - Hosting
|
||||
|
||||
<!-- - Support for [opensignpdf (requires Java on server)](https://github.com/open-pdf-sign) is currently planned. -->
|
||||
|
||||
@ -101,7 +108,7 @@ Contact us if you are interested in our Enterprise plan for large organizations
|
||||
|
||||
To run Documenso locally, you will need
|
||||
|
||||
- Node.js (v22 or above)
|
||||
- Node.js (v18 or above)
|
||||
- Postgres SQL Database
|
||||
- Docker (optional)
|
||||
|
||||
@ -164,8 +171,10 @@ git clone https://github.com/<your-username>/documenso
|
||||
|
||||
4. Set the following environment variables:
|
||||
|
||||
- NEXTAUTH_URL
|
||||
- NEXTAUTH_SECRET
|
||||
- NEXT_PUBLIC_WEBAPP_URL
|
||||
- NEXT_PUBLIC_MARKETING_URL
|
||||
- NEXT_PRIVATE_DATABASE_URL
|
||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||
@ -234,14 +243,16 @@ cp .env.example .env
|
||||
|
||||
The following environment variables must be set:
|
||||
|
||||
- `NEXTAUTH_URL`
|
||||
- `NEXTAUTH_SECRET`
|
||||
- `NEXT_PUBLIC_WEBAPP_URL`
|
||||
- `NEXT_PUBLIC_MARKETING_URL`
|
||||
- `NEXT_PRIVATE_DATABASE_URL`
|
||||
- `NEXT_PRIVATE_DIRECT_DATABASE_URL`
|
||||
- `NEXT_PRIVATE_SMTP_FROM_NAME`
|
||||
- `NEXT_PRIVATE_SMTP_FROM_ADDRESS`
|
||||
|
||||
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for the `NEXT_PUBLIC_WEBAPP_URL` variable!
|
||||
> If you are using a reverse proxy in front of Documenso, don't forget to provide the public URL for both `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
|
||||
|
||||
Now you can install the dependencies and build it:
|
||||
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start -p 3002",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules"
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/assets": "*",
|
||||
|
||||
@ -14,4 +14,4 @@
|
||||
"public-api": "Public API",
|
||||
"embedding": "Embedding",
|
||||
"webhooks": "Webhooks"
|
||||
}
|
||||
}
|
||||
@ -52,9 +52,9 @@ Platform customers have access to advanced styling options to customize the embe
|
||||
<EmbedDirectTemplate
|
||||
token={token}
|
||||
cssVars={{
|
||||
primary: '#0000FF',
|
||||
background: '#F5F5F5',
|
||||
radius: '8px',
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
||||
}
|
||||
`;
|
||||
const cssVars = {
|
||||
primary: '#0000FF',
|
||||
background: '#F5F5F5',
|
||||
radius: '8px',
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -99,9 +99,9 @@ const MyEmbeddingComponent = () => {
|
||||
`}
|
||||
// CSS Variables
|
||||
cssVars={{
|
||||
primary: '#0000FF',
|
||||
background: '#F5F5F5',
|
||||
radius: '8px',
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
// Dark Mode Control
|
||||
darkModeDisabled={true}
|
||||
|
||||
@ -95,9 +95,9 @@ const MyEmbeddingComponent = () => {
|
||||
}
|
||||
`;
|
||||
const cssVars = {
|
||||
primary: '#0000FF',
|
||||
background: '#F5F5F5',
|
||||
radius: '8px',
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
||||
}
|
||||
`;
|
||||
const cssVars = {
|
||||
primary: '#0000FF',
|
||||
background: '#F5F5F5',
|
||||
radius: '8px',
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@ -97,9 +97,9 @@ Platform customers have access to advanced styling options:
|
||||
}
|
||||
`;
|
||||
const cssVars = {
|
||||
primary: '#0000FF',
|
||||
background: '#F5F5F5',
|
||||
radius: '8px',
|
||||
colorPrimary: '#0000FF',
|
||||
colorBackground: '#F5F5F5',
|
||||
borderRadius: '8px',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@ -16,16 +16,18 @@ Pick the one that fits your needs the best.
|
||||
## Tech Stack
|
||||
|
||||
- [Typescript](https://www.typescriptlang.org/) - Language
|
||||
- [React Router](https://reactrouter.com/) - Framework
|
||||
- [Next.js](https://nextjs.org/) - Framework
|
||||
- [Prisma](https://www.prisma.io/) - ORM
|
||||
- [Tailwind](https://tailwindcss.com/) - CSS
|
||||
- [shadcn/ui](https://ui.shadcn.com/) - Component Library
|
||||
- [NextAuth.js](https://next-auth.js.org/) - Authentication
|
||||
- [react-email](https://react.email/) - Email Templates
|
||||
- [tRPC](https://trpc.io/) - API
|
||||
- [@documenso/pdf-sign](https://www.npmjs.com/package/@documenso/pdf-sign) - PDF Signatures
|
||||
- [React-PDF](https://github.com/wojtekmaj/react-pdf) - Viewing PDFs
|
||||
- [PDF-Lib](https://github.com/Hopding/pdf-lib) - PDF manipulation
|
||||
- [Stripe](https://stripe.com/) - Payments
|
||||
- [Vercel](https://vercel.com) - Hosting
|
||||
|
||||
<div className="mt-16 flex items-center justify-center gap-4">
|
||||
<a href="https://documen.so/discord">
|
||||
|
||||
@ -32,8 +32,10 @@ Run `npm i` in the root directory to install the dependencies required for the p
|
||||
Set up the following environment variables in the `.env` file:
|
||||
|
||||
```bash
|
||||
NEXTAUTH_URL
|
||||
NEXTAUTH_SECRET
|
||||
NEXT_PUBLIC_WEBAPP_URL
|
||||
NEXT_PUBLIC_MARKETING_URL
|
||||
NEXT_PRIVATE_DATABASE_URL
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME
|
||||
|
||||
@ -13,13 +13,35 @@ Documenso uses the following stack to handle translations:
|
||||
|
||||
Additional reading can be found in the [Lingui documentation](https://lingui.dev/introduction).
|
||||
|
||||
## Requirements
|
||||
|
||||
You **must** insert **`setupI18nSSR()`** when creating any of the following files:
|
||||
|
||||
- Server layout.tsx
|
||||
- Server page.tsx
|
||||
- Server loading.tsx
|
||||
|
||||
Server meaning it does not have `'use client'` in it.
|
||||
|
||||
```tsx
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
export default function SomePage() {
|
||||
setupI18nSSR(); // Required if there are translations within the page, or nested in components.
|
||||
|
||||
// Rest of code...
|
||||
}
|
||||
```
|
||||
|
||||
Additional information can be found [here.](https://lingui.dev/tutorials/react-rsc#pages-layouts-and-lingui)
|
||||
|
||||
## Quick guide
|
||||
|
||||
If you require more in-depth information, please see the [Lingui documentation](https://lingui.dev/introduction).
|
||||
|
||||
### HTML
|
||||
|
||||
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/react/macro**.
|
||||
Wrap all text to translate in **`<Trans></Trans>`** tags exported from **@lingui/macro** (not @lingui/react).
|
||||
|
||||
```html
|
||||
<h1>
|
||||
@ -42,9 +64,8 @@ For text that is broken into elements, but represent a whole sentence, you must
|
||||
### Constants outside of react components
|
||||
|
||||
```tsx
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
// Wrap text in msg`text to translate` when it's in a constant here, or another file/package.
|
||||
export const CONSTANT_WITH_MSG = {
|
||||
@ -77,13 +98,31 @@ Lingui provides a Plural component to make it easy. See full documentation [here
|
||||
|
||||
Lingui provides a [DateTime instance](https://lingui.dev/ref/core#i18n.date) with the configured locale.
|
||||
|
||||
#### Server components
|
||||
|
||||
Note that the i18n instance is coming from **setupI18nSSR**.
|
||||
|
||||
```tsx
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
export const SomeComponent = () => {
|
||||
const { i18n } = useLingui();
|
||||
const { i18n } = setupI18nSSR();
|
||||
|
||||
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
||||
};
|
||||
```
|
||||
|
||||
#### Client components
|
||||
|
||||
Note that the i18n instance is coming from the **import**.
|
||||
|
||||
```tsx
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
export const SomeComponent = () => {
|
||||
return <Trans>The current date is {i18n.date(new Date(), { dateStyle: 'short' })}</Trans>;
|
||||
};
|
||||
```
|
||||
|
||||
@ -21,25 +21,14 @@ Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) f
|
||||
|
||||
## API V2 - Beta
|
||||
|
||||
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
||||
Our new API V2 is currently in Beta. The new API features typed SDKs for TypeScript, Python and Go and example code for many more.
|
||||
|
||||
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 type="warning">
|
||||
NOW IN BETA: [API V2 Documentation](https://documen.so/api-v2-docs)
|
||||
</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)
|
||||
|
||||
@ -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)
|
||||
|
||||
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 { CallToAction } from '../../../components/call-to-action';
|
||||
import { CallToAction } from '@documenso/ui/components/call-to-action';
|
||||
|
||||
# Self Hosting
|
||||
|
||||
@ -35,8 +35,10 @@ cp .env.example .env
|
||||
Open the `.env` file and fill in the following variables:
|
||||
|
||||
```bash
|
||||
- NEXTAUTH_URL
|
||||
- NEXTAUTH_SECRET
|
||||
- NEXT_PUBLIC_WEBAPP_URL
|
||||
- NEXT_PUBLIC_MARKETING_URL
|
||||
- NEXT_PRIVATE_DATABASE_URL
|
||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL
|
||||
- NEXT_PRIVATE_SMTP_FROM_NAME
|
||||
@ -44,8 +46,8 @@ Open the `.env` file and fill in the following variables:
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for the
|
||||
`NEXT_PUBLIC_WEBAPP_URL` variable!
|
||||
If you use a reverse proxy in front of Documenso, don't forget to provide the public URL for both
|
||||
the `NEXTAUTH_URL` and `NEXT_PUBLIC_WEBAPP_URL` variables!
|
||||
</Callout>
|
||||
|
||||
### Install the Dependencies
|
||||
@ -169,6 +171,7 @@ Run the Docker container with the required environment variables:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e NEXTAUTH_URL="<your-nextauth-url>"
|
||||
-e NEXTAUTH_SECRET="<your-nextauth-secret>"
|
||||
-e NEXT_PRIVATE_ENCRYPTION_KEY="<your-next-private-encryption-key>"
|
||||
-e NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="<your-next-private-encryption-secondary-key>"
|
||||
@ -197,6 +200,7 @@ The environment variables listed above are a subset of those available for confi
|
||||
| Variable | Description |
|
||||
| -------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | The port on which the Documenso application runs. It defaults to `3000`. |
|
||||
| `NEXTAUTH_URL` | The URL for the NextAuth.js authentication service. |
|
||||
| `NEXTAUTH_SECRET` | The secret key used by NextAuth.js for encryption and signing. |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_KEY` | The primary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
| `NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY` | The secondary encryption key for symmetric encryption and decryption (at least 32 characters). |
|
||||
|
||||
@ -3,7 +3,7 @@ title: Getting Started with Self-Hosting
|
||||
description: A step-by-step guide to setting up and hosting your own Documenso instance.
|
||||
---
|
||||
|
||||
import { CallToAction } from '../../../components/call-to-action';
|
||||
import { CallToAction } from '@documenso/ui/components/call-to-action';
|
||||
|
||||
# Getting Started with Self-Hosting
|
||||
|
||||
|
||||
@ -85,13 +85,12 @@ You can also set the recipient's role, which determines their actions and permis
|
||||
|
||||
Documenso has 4 roles for recipients with different permissions and actions.
|
||||
|
||||
| Role | Function | Action required | Signature |
|
||||
| :-------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||
| Assistant | Can help prepare the document by filling in fields on behalf of other signers. | Yes | No |
|
||||
| CC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||
| Role | Function | Action required | Signature |
|
||||
| :------: | :-----------------------------------------------------------------------------: | :-------------: | :-------: |
|
||||
| Signer | Needs to sign signatures fields assigned to them. | Yes | Yes |
|
||||
| Approver | Needs to approve the document as a whole. Signature optional. | Yes | Optional |
|
||||
| Viewer | Needs to confirm they viewed the document. | Yes | No |
|
||||
| BCC | Receives a copy of the signed document after completion. No action is required. | No | No |
|
||||
|
||||
### Fields
|
||||
|
||||
|
||||
@ -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 { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
import { addZeroMonth } from '../add-zero-month';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
@ -37,7 +35,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative'
|
||||
],
|
||||
};
|
||||
|
||||
return addZeroMonth(transformedData);
|
||||
return transformedData;
|
||||
};
|
||||
|
||||
export type GetCompletedDocumentsMonthlyResult = Awaited<
|
||||
|
||||
@ -2,8 +2,6 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.selectFrom('Recipient')
|
||||
@ -36,7 +34,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' =
|
||||
],
|
||||
};
|
||||
|
||||
return addZeroMonth(transformedData);
|
||||
return transformedData;
|
||||
};
|
||||
|
||||
export type GetSignerConversionMonthlyResult = Awaited<
|
||||
|
||||
@ -2,8 +2,6 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||
|
||||
import { addZeroMonth } from '../add-zero-month';
|
||||
|
||||
export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count') => {
|
||||
const qb = kyselyPrisma.$kysely
|
||||
.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>>;
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { addZeroMonth } from './add-zero-month';
|
||||
|
||||
type MetricKeys = {
|
||||
stars: number;
|
||||
forks: number;
|
||||
@ -39,77 +37,31 @@ export function transformData({
|
||||
data: DataEntry;
|
||||
metric: MetricKey;
|
||||
}): TransformData {
|
||||
try {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return {
|
||||
labels: [],
|
||||
datasets: [{ label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`, data: [] }],
|
||||
};
|
||||
}
|
||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||
|
||||
const sortedEntries = Object.entries(data).sort(([dateA], [dateB]) => {
|
||||
try {
|
||||
const [yearA, monthA] = dateA.split('-').map(Number);
|
||||
const [yearB, monthB] = dateB.split('-').map(Number);
|
||||
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
||||
});
|
||||
|
||||
if (isNaN(yearA) || isNaN(monthA) || isNaN(yearB) || isNaN(monthB)) {
|
||||
console.warn(`Invalid date format: ${dateA} or ${dateB}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DateTime.local(yearA, monthA).toMillis() - DateTime.local(yearB, monthB).toMillis();
|
||||
} catch (error) {
|
||||
console.error('Error sorting entries:', error);
|
||||
return 0;
|
||||
}
|
||||
const labels = sortedEntries.map(([date]) => {
|
||||
const [year, month] = date.split('-');
|
||||
const dateTime = DateTime.fromObject({
|
||||
year: Number(year),
|
||||
month: Number(month),
|
||||
});
|
||||
return dateTime.toFormat('MMM yyyy');
|
||||
});
|
||||
|
||||
const labels = sortedEntries.map(([date]) => {
|
||||
try {
|
||||
const [year, month] = date.split('-');
|
||||
|
||||
if (!year || !month || isNaN(Number(year)) || isNaN(Number(month))) {
|
||||
console.warn(`Invalid date format: ${date}`);
|
||||
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: [] }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: `Total ${FRIENDLY_METRIC_NAMES[metric]}`,
|
||||
data: sortedEntries.map(([_, stats]) => stats[metric]),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// To be on the safer side
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint:fix": "next lint --fix",
|
||||
"clean": "rimraf .next && rimraf node_modules"
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
|
||||
@ -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,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,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,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,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.
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
BarChart3,
|
||||
Building2Icon,
|
||||
FileStack,
|
||||
Settings,
|
||||
Trophy,
|
||||
Users,
|
||||
Wallet2,
|
||||
} from 'lucide-react';
|
||||
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { isAdmin } from '@documenso/lib/utils/is-admin';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const { user } = await getSession(request);
|
||||
|
||||
if (!user || !isAdmin(user)) {
|
||||
throw redirect('/documents');
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="text-4xl font-semibold">
|
||||
<Trans>Admin Panel</Trans>
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-12 flex gap-x-2.5 gap-y-2 overflow-hidden overflow-x-auto md:col-span-3 md:flex md:flex-col',
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/stats') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/stats">
|
||||
<BarChart3 className="mr-2 h-5 w-5" />
|
||||
<Trans>Stats</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/organisations') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/organisations">
|
||||
<Building2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Organisations</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/claims') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/claims">
|
||||
<Wallet2 className="mr-2 h-5 w-5" />
|
||||
<Trans>Claims</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/users') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/users">
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Users</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/documents') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/documents">
|
||||
<FileStack className="mr-2 h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/leaderboard">
|
||||
<Trophy className="mr-2 h-5 w-5" />
|
||||
<Trans>Leaderboard</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/banner') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link to="/admin/site-settings">
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
<Trans>Site Settings</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { AdminClaimsTable } from '~/components/tables/admin-claims-table';
|
||||
|
||||
export default function Claims() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Subscription Claims`}
|
||||
subtitle={t`Manage all subscription claims`}
|
||||
hideDivider
|
||||
>
|
||||
<ClaimCreateDialog />
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t`Search by claim ID or name`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<AdminClaimsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,167 +0,0 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@documenso/ui/primitives/accordion';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
|
||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
|
||||
|
||||
import type { Route } from './+types/documents.$id';
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const id = Number(params.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
throw redirect('/admin/documents');
|
||||
}
|
||||
|
||||
const document = await getEntireDocument({ id });
|
||||
|
||||
return { document };
|
||||
}
|
||||
|
||||
export default function AdminDocumentDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { document } = loaderData;
|
||||
|
||||
const { _, i18n } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
|
||||
trpc.admin.resealDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Document resealed`),
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`Failed to reseal document`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<h1 className="text-2xl font-semibold">{document.title}</h1>
|
||||
<DocumentStatus status={document.status} />
|
||||
</div>
|
||||
|
||||
{document.deletedAt && (
|
||||
<Badge size="large" variant="destructive">
|
||||
<Trans>Deleted</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-4 text-sm">
|
||||
<div>
|
||||
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Admin Actions</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-2 flex gap-x-4">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isResealDocumentLoading}
|
||||
disabled={document.recipients.some(
|
||||
(recipient) =>
|
||||
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||
)}
|
||||
onClick={() => resealDocument({ id: document.id })}
|
||||
>
|
||||
<Trans>Reseal document</Trans>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="max-w-[40ch]">
|
||||
<Trans>
|
||||
Attempts sealing the document again, useful for after a code change has occurred to
|
||||
resolve an erroneous document.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/users/${document.userId}`}>
|
||||
<Trans>Go to owner</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
<Trans>Recipients</Trans>
|
||||
</h2>
|
||||
|
||||
<div className="mt-4">
|
||||
<Accordion type="multiple" className="space-y-4">
|
||||
{document.recipients.map((recipient) => (
|
||||
<AccordionItem
|
||||
key={recipient.id}
|
||||
value={recipient.id.toString()}
|
||||
className="rounded-lg border"
|
||||
>
|
||||
<AccordionTrigger className="px-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<h4 className="font-semibold">{recipient.name}</h4>
|
||||
<Badge size="small" variant="neutral">
|
||||
{recipient.email}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="border-t px-4 pt-4">
|
||||
<AdminDocumentRecipientItemTable recipient={recipient} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
{document && <AdminDocumentDeleteDialog document={document} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
|
||||
|
||||
import { AdminLeaderboardTable } from '~/components/tables/admin-leaderboard-table';
|
||||
|
||||
import type { Route } from './+types/leaderboard';
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
|
||||
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
|
||||
| 'asc'
|
||||
| 'desc';
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const sortBy = (
|
||||
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
|
||||
) as 'name' | 'createdAt' | 'signingVolume';
|
||||
|
||||
const page = Number(url.searchParams.get('page')) || 1;
|
||||
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||
const search = url.searchParams.get('search') || '';
|
||||
|
||||
const { leaderboard: signingVolume, totalPages } = await getSigningVolume({
|
||||
search,
|
||||
page,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
return {
|
||||
signingVolume,
|
||||
totalPages,
|
||||
page,
|
||||
perPage,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
|
||||
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Signing Volume</Trans>
|
||||
</h2>
|
||||
<div className="mt-8">
|
||||
<AdminLeaderboardTable
|
||||
signingVolume={signingVolume}
|
||||
totalPages={totalPages}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,571 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
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 { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/organisations.$id';
|
||||
|
||||
export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const organisationId = params.id;
|
||||
|
||||
const { data: organisation, isLoading: isLoadingOrganisation } =
|
||||
trpc.admin.organisation.get.useQuery({
|
||||
organisationId,
|
||||
});
|
||||
|
||||
const { mutateAsync: createStripeCustomer, isPending: isCreatingStripeCustomer } =
|
||||
trpc.admin.stripe.createCustomer.useMutation({
|
||||
onSuccess: async () => {
|
||||
await navigate(0);
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Stripe customer created successfully`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`We couldn't create a Stripe customer. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const teamsColumns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Team`,
|
||||
accessorKey: 'name',
|
||||
},
|
||||
{
|
||||
header: t`Team url`,
|
||||
accessorKey: 'url',
|
||||
},
|
||||
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['teams'][number]>[];
|
||||
}, []);
|
||||
|
||||
const organisationMembersColumns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Member`,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.name}</Link>
|
||||
{row.original.user.id === organisation?.ownerUserId && <Badge>Owner</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t`Email`,
|
||||
cell: ({ row }) => (
|
||||
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
||||
}, [organisation]);
|
||||
|
||||
if (isLoadingOrganisation) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!organisation) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={404}
|
||||
errorCodeMap={{
|
||||
404: {
|
||||
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.`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/admin/organisations`}>
|
||||
<Trans>Go back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Manage organisation`}
|
||||
subtitle={t`Manage the ${organisation.name} organisation`}
|
||||
/>
|
||||
|
||||
<GenericOrganisationAdminForm organisation={organisation} />
|
||||
|
||||
<SettingsHeader
|
||||
title={t`Manage subscription`}
|
||||
subtitle={t`Manage the ${organisation.name} organisation subscription`}
|
||||
className="mt-16"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Subscription</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
{organisation.subscription ? (
|
||||
<span>
|
||||
{SUBSCRIPTION_STATUS_MAP[organisation.subscription.status]} subscription found
|
||||
</span>
|
||||
) : (
|
||||
<span>No subscription found</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{!organisation.customerId && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isCreatingStripeCustomer}
|
||||
onClick={async () => createStripeCustomer({ organisationId })}
|
||||
>
|
||||
<Trans>Create Stripe customer</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organisation.customerId && !organisation.subscription && (
|
||||
<div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
|
||||
>
|
||||
<Trans>Create subscription</Trans>
|
||||
<ExternalLinkIcon className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{organisation.subscription && (
|
||||
<div>
|
||||
<Button variant="outline" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
|
||||
>
|
||||
<Trans>Manage subscription</Trans>
|
||||
<ExternalLinkIcon className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<OrganisationAdminForm organisation={organisation} />
|
||||
|
||||
<div className="mt-16 space-y-10">
|
||||
<div>
|
||||
<label className="text-sm font-medium leading-none">
|
||||
<Trans>Organisation Members</Trans>
|
||||
</label>
|
||||
|
||||
<div className="my-2">
|
||||
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium leading-none">
|
||||
<Trans>Organisation Teams</Trans>
|
||||
</label>
|
||||
|
||||
<div className="my-2">
|
||||
<DataTable columns={teamsColumns} data={organisation.teams} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ZUpdateGenericOrganisationDataFormSchema =
|
||||
ZUpdateAdminOrganisationRequestSchema.shape.data.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
});
|
||||
|
||||
type TUpdateGenericOrganisationDataFormSchema = z.infer<
|
||||
typeof ZUpdateGenericOrganisationDataFormSchema
|
||||
>;
|
||||
|
||||
type OrganisationAdminFormOptions = {
|
||||
organisation: TGetAdminOrganisationResponse;
|
||||
};
|
||||
|
||||
const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
|
||||
|
||||
const form = useForm<TUpdateGenericOrganisationDataFormSchema>({
|
||||
resolver: zodResolver(ZUpdateGenericOrganisationDataFormSchema),
|
||||
defaultValues: {
|
||||
name: organisation.name,
|
||||
url: organisation.url,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: TUpdateGenericOrganisationDataFormSchema) => {
|
||||
try {
|
||||
await updateOrganisation({
|
||||
organisationId: organisation.id,
|
||||
data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Organisation has been updated successfully`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: t`We couldn't update the organisation. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Organisation Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Organisation URL</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...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 the organisation</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSchema.shape.data.pick({
|
||||
claims: true,
|
||||
customerId: true,
|
||||
originalSubscriptionClaimId: true,
|
||||
});
|
||||
|
||||
type TUpdateOrganisationBillingFormSchema = z.infer<typeof ZUpdateOrganisationBillingFormSchema>;
|
||||
|
||||
const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation();
|
||||
|
||||
const form = useForm<TUpdateOrganisationBillingFormSchema>({
|
||||
resolver: zodResolver(ZUpdateOrganisationBillingFormSchema),
|
||||
defaultValues: {
|
||||
customerId: organisation.customerId || '',
|
||||
claims: {
|
||||
teamCount: organisation.organisationClaim.teamCount,
|
||||
memberCount: organisation.organisationClaim.memberCount,
|
||||
flags: organisation.organisationClaim.flags,
|
||||
},
|
||||
originalSubscriptionClaimId: organisation.organisationClaim.originalSubscriptionClaimId || '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: TUpdateOrganisationBillingFormSchema) => {
|
||||
try {
|
||||
await updateOrganisation({
|
||||
organisationId: organisation.id,
|
||||
data: values,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`Organisation has been updated successfully`,
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: t`We couldn't update the organisation. Please try again.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="originalSubscriptionClaimId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center">
|
||||
<Trans>Inherited subscription claim</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
|
||||
<h2>
|
||||
<strong>
|
||||
<Trans>Inherited subscription claim</Trans>
|
||||
</strong>
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
This is the claim that this organisation was initially created with. Any
|
||||
feature flag changes to this claim will be backported into this
|
||||
organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
For example, if the claim has a new flag "FLAG_1" set to true, then this
|
||||
organisation will get that flag added.
|
||||
</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans>
|
||||
This will ONLY backport feature flags which are set to true, anything
|
||||
disabled in the initial claim will not be backported
|
||||
</Trans>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Stripe Customer ID</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={t`No Stripe customer attached`} />
|
||||
</FormControl>
|
||||
{!form.formState.errors.customerId && field.value && (
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/customers/${field.value}`}
|
||||
className="text-foreground/50 text-xs font-normal"
|
||||
>
|
||||
{`https://dashboard.stripe.com/customers/${field.value}`}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.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="claims.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={`claims.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>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -1,62 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
|
||||
|
||||
export default function Organisations() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||
|
||||
/**
|
||||
* Handle debouncing the search query.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set('query', debouncedSearchQuery);
|
||||
|
||||
if (debouncedSearchQuery === '') {
|
||||
params.delete('query');
|
||||
}
|
||||
|
||||
// If nothing to change then do nothing.
|
||||
if (params.toString() === searchParams?.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchParams(params);
|
||||
}, [debouncedSearchQuery, pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
hideDivider
|
||||
title={t`Manage organisations`}
|
||||
subtitle={t`Search and manage all organisations`}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
defaultValue={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<AdminOrganisationsTable />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,224 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
|
||||
import {
|
||||
SITE_SETTINGS_BANNER_ID,
|
||||
ZSiteSettingsBannerSchema,
|
||||
} from '@documenso/lib/server-only/site-settings/schemas/banner';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/site-settings';
|
||||
|
||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
||||
|
||||
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
|
||||
|
||||
export async function loader() {
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
|
||||
return { banner };
|
||||
}
|
||||
|
||||
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||
const { banner } = loaderData;
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const form = useForm<TBannerFormSchema>({
|
||||
resolver: zodResolver(ZBannerFormSchema),
|
||||
defaultValues: {
|
||||
id: SITE_SETTINGS_BANNER_ID,
|
||||
enabled: banner?.enabled ?? false,
|
||||
data: {
|
||||
content: banner?.data?.content ?? '',
|
||||
bgColor: banner?.data?.bgColor ?? '#000000',
|
||||
textColor: banner?.data?.textColor ?? '#FFFFFF',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const enabled = form.watch('enabled');
|
||||
|
||||
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
|
||||
trpcReact.admin.updateSiteSetting.useMutation();
|
||||
|
||||
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
|
||||
try {
|
||||
await updateSiteSetting({
|
||||
id,
|
||||
enabled,
|
||||
data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Banner Updated`),
|
||||
description: _(msg`Your banner has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
variant: 'destructive',
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={_(msg`Site Settings`)}
|
||||
subtitle={_(msg`Manage your site settings here`)}
|
||||
/>
|
||||
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
<h2 className="font-semibold">
|
||||
<Trans>Site Banner</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
The site banner is a message that is shown at the top of the site. It can be used to
|
||||
display important information to your users.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4 flex flex-col rounded-md"
|
||||
onSubmit={form.handleSubmit(onBannerUpdate)}
|
||||
>
|
||||
<div className="mt-4 flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Enabled</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<fieldset
|
||||
className="flex flex-col gap-4 md:flex-row"
|
||||
disabled={!enabled}
|
||||
aria-disabled={!enabled}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.bgColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.textColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div>
|
||||
<ColorPicker {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!enabled} aria-disabled={!enabled}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="data.content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Textarea className="h-32 resize-none" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The content to show in the banner, HTML is allowed</Trans>
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isUpdateSiteSettingLoading}
|
||||
className="mt-4 justify-end self-end"
|
||||
>
|
||||
<Trans>Update Banner</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
|
||||
|
||||
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
|
||||
|
||||
import type { Route } from './+types/users._index';
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const page = Number(url.searchParams.get('page')) || 1;
|
||||
const perPage = Number(url.searchParams.get('perPage')) || 10;
|
||||
const search = url.searchParams.get('search') || '';
|
||||
|
||||
const [{ users, totalPages }] = await Promise.all([
|
||||
findUsers({ username: search, email: search, page, perPage }),
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
totalPages,
|
||||
page,
|
||||
perPage,
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminManageUsersPage({ loaderData }: Route.ComponentProps) {
|
||||
const { users, totalPages, page, perPage } = loaderData;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-4xl font-semibold">
|
||||
<Trans>Manage users</Trans>
|
||||
</h2>
|
||||
|
||||
<AdminDashboardUsersTable
|
||||
users={users}
|
||||
totalPages={totalPages}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,235 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Plural, Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Building2Icon, InboxIcon, SettingsIcon, UsersIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ScrollArea, ScrollBar } from '@documenso/ui/primitives/scroll-area';
|
||||
|
||||
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
|
||||
import { InboxTable } from '~/components/tables/inbox-table';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Dashboard');
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { user, organisations } = useSession();
|
||||
|
||||
// Todo: Sort by recent access (TBD by cookies)
|
||||
// Teams, flattened with the organisation data still attached.
|
||||
const teams = useMemo(() => {
|
||||
return organisations.flatMap((org) =>
|
||||
org.teams.map((team) => ({
|
||||
...team,
|
||||
organisation: {
|
||||
...org,
|
||||
teams: undefined,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}, [organisations]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<div className="container">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">
|
||||
<Trans>Dashboard</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
<Trans>Welcome back! Here's an overview of your account.</Trans>
|
||||
</p>
|
||||
|
||||
<OrganisationInvitations className="mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Organisations Section */}
|
||||
{organisations.length > 1 && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2Icon className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Trans>Organisations</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Right hand side action if required. */}
|
||||
{/* <Button variant="outline" size="sm" className="gap-1">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<Trans>New</Trans>
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{organisations.map((org) => (
|
||||
<div key={org.id} className="group relative">
|
||||
<Link to={`/org/${org.url}`}>
|
||||
<Card className="hover:bg-muted/50 h-full border pr-6 transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 border border-solid">
|
||||
{org.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(org.avatarImageId)} />
|
||||
)}
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{org.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{org.name}</h3>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
<span>
|
||||
{org.ownerUserId === user.id
|
||||
? t`Owner`
|
||||
: t(ORGANISATION_MEMBER_ROLE_MAP[org.currentOrganisationRole])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2Icon className="h-3 w-3" />
|
||||
<span>
|
||||
<Plural
|
||||
value={org.teams.length}
|
||||
one={<Trans># team</Trans>}
|
||||
other={<Trans># teams</Trans>}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{canExecuteOrganisationAction(
|
||||
'MANAGE_ORGANISATION',
|
||||
org.currentOrganisationRole,
|
||||
) && (
|
||||
<div className="text-muted-foreground absolute right-4 top-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Link to={`/org/${org.url}/settings`}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Teams Section */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Trans>Teams</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
{/* <Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/" className="gap-1">
|
||||
<Trans>View all</Trans>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="w-full whitespace-nowrap pb-4">
|
||||
<div className="flex gap-4">
|
||||
{teams.map((team) => (
|
||||
<div key={team.id} className="group relative">
|
||||
<Link to={`/t/${team.url}`}>
|
||||
<Card className="hover:bg-muted/50 w-[350px] shrink-0 border transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 border border-solid">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||
)}
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{team.name}</h3>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
{team.organisation.ownerUserId === user.id
|
||||
? t`Owner`
|
||||
: t(TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Building2Icon className="h-3 w-3" />
|
||||
<span className="truncate">{team.organisation.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-3 text-xs">
|
||||
<Trans>
|
||||
Joined{' '}
|
||||
{DateTime.fromJSDate(team.createdAt).toRelative({ style: 'short' })}
|
||||
</Trans>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole) && (
|
||||
<div className="text-muted-foreground absolute right-4 top-4 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<Link to={`/t/${team.url}/settings`}>
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Inbox Section */}
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<InboxIcon className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-xl font-semibold">
|
||||
<Trans>Personal Inbox</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
{/* <Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/inbox" className="gap-1">
|
||||
<span>
|
||||
<Trans>View all</Trans>
|
||||
</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button> */}
|
||||
</div>
|
||||
|
||||
<InboxTable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,223 +0,0 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowRight,
|
||||
CalendarIcon,
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
TrashIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { canExecuteTeamAction, formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||
import type { TGetOrganisationSessionResponse } from '@documenso/trpc/server/organisation-router/get-organisation-session.types';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
|
||||
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
|
||||
|
||||
export default function OrganisationSettingsTeamsPage() {
|
||||
const { t, i18n } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
// No teams view.
|
||||
if (organisation.teams.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-16">
|
||||
<div className="bg-muted mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
||||
<UsersIcon className="text-muted-foreground h-10 w-10" />
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 text-xl font-semibold">
|
||||
<Trans>No teams yet</Trans>
|
||||
</h2>
|
||||
|
||||
{canExecuteOrganisationAction(
|
||||
'MANAGE_ORGANISATION',
|
||||
organisation.currentOrganisationRole,
|
||||
) ? (
|
||||
<>
|
||||
<p className="text-muted-foreground mb-8 max-w-md text-center text-sm">
|
||||
<Trans>
|
||||
Teams help you organize your work and collaborate with others. Create your first
|
||||
team to get started.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<TeamCreateDialog
|
||||
trigger={
|
||||
<Button className="flex items-center gap-2">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<Trans>Create team</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-12 max-w-md rounded-lg border px-8 py-6">
|
||||
<h3 className="mb-2 font-medium">
|
||||
<Trans>What you can do with teams:</Trans>
|
||||
</h3>
|
||||
<ul className="text-muted-foreground space-y-2 text-sm">
|
||||
<li className="flex flex-row items-center gap-2">
|
||||
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
|
||||
<span className="text-xs">1</span>
|
||||
</div>
|
||||
<Trans>Organize your documents and templates</Trans>
|
||||
</li>
|
||||
<li className="flex flex-row items-center gap-2">
|
||||
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
|
||||
<span className="text-xs">2</span>
|
||||
</div>
|
||||
<Trans>Invite team members to collaborate</Trans>
|
||||
</li>
|
||||
<li className="flex flex-row items-center gap-2">
|
||||
<div className="bg-muted mt-0.5 flex h-5 w-5 items-center justify-center rounded-full font-bold">
|
||||
<span className="text-xs">3</span>
|
||||
</div>
|
||||
<Trans>Manage permissions and access controls</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground mb-8 max-w-md text-center text-sm">
|
||||
<Trans>
|
||||
You currently have no access to any teams within this organisation. Please contact
|
||||
your organisation to request access.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-row justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<Trans>{organisation.name} Teams</Trans>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Select a team to view its dashboard</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild>
|
||||
<Link to={`/org/${organisation.url}/settings`}>Manage Organisation</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{organisation.teams.map((team) => (
|
||||
<Link to={`/t/${team.url}`} key={team.id}>
|
||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 border-2 border-solid">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||
)}
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">{team.name}</h3>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{formatTeamUrl(team.url)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TeamDropdownMenu team={team} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-4">
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
{i18n.date(team.createdAt, { dateStyle: 'short' })}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
<span>{t(TEAM_MEMBER_ROLE_MAP[team.currentTeamRole])}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['teams'][0] }) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/t/${team.url}`}>
|
||||
<ArrowRight className="mr-2 h-4 w-4" />
|
||||
<Trans>Go to team</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/t/${team.url}/settings`}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Settings</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/t/${team.url}/settings/members`}>
|
||||
<UsersIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Members</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canExecuteTeamAction('DELETE_TEAM', team.currentTeamRole) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<TeamDeleteDialog
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user