mirror of
https://github.com/documenso/documenso.git
synced 2026-07-13 22:37:24 +10:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12f3b7629e | |||
| 1d7f3723bc | |||
| 4c57095ee1 | |||
| 15922d447b | |||
| 548d92c2fc | |||
| d24f67d922 | |||
| 5b395fc9ad | |||
| e128e9369e | |||
| f5bfec1990 | |||
| 82b5795636 | |||
| 4aec21a37f | |||
| 19dc43dca1 | |||
| d3392dada7 | |||
| 8373af3f41 | |||
| e5cc6455dd | |||
| b127fae0e0 | |||
| 6fa3751a72 | |||
| d164b90aa3 | |||
| 738201eb55 | |||
| 7effe66387 | |||
| 9c7910a070 | |||
| f55ccb21dd | |||
| 6b4c33a1bf | |||
| f4b2f8614e | |||
| 1057ae6d2a | |||
| 540cc5bfc1 | |||
| 381a9d3fb8 | |||
| e5a9d9ddf0 | |||
| d1913dbf9c | |||
| 8bffa7c3ed | |||
| b2af10173a | |||
| 28fb35327d | |||
| e20cb7e179 | |||
| aec44b78d0 | |||
| d7d0fca501 | |||
| f7a98180d7 |
+16
-43
@@ -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
|
||||
@@ -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=
|
||||
|
||||
@@ -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,7 +2,7 @@ name: 'Setup node and cache node_modules'
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: v22.x
|
||||
default: v20.x
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -111,83 +111,6 @@ The colors will be automatically converted to the appropriate format internally.
|
||||
|
||||
4. **Consistent Radius**: Use a consistent border radius value that matches your application's design system.
|
||||
|
||||
## CSS Class Targets
|
||||
|
||||
In addition to CSS variables, specific components in the embedded experience can be targeted using CSS classes for more granular styling:
|
||||
|
||||
### Component Classes
|
||||
|
||||
| Class Name | Description |
|
||||
| --------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `.embed--Root` | Main container for the embedded signing experience |
|
||||
| `.embed--DocumentContainer` | Container for the document and signing widget |
|
||||
| `.embed--DocumentViewer` | Container for the document viewer |
|
||||
| `.embed--DocumentWidget` | The signing widget container |
|
||||
| `.embed--DocumentWidgetContainer` | Outer container for the signing widget, handles positioning |
|
||||
| `.embed--DocumentWidgetHeader` | Header section of the signing widget |
|
||||
| `.embed--DocumentWidgetContent` | Main content area of the signing widget |
|
||||
| `.embed--DocumentWidgetForm` | Form section within the signing widget |
|
||||
| `.embed--DocumentWidgetFooter` | Footer section of the signing widget |
|
||||
| `.embed--WaitingForTurn` | Container for the waiting screen when it's not the user's turn to sign |
|
||||
| `.embed--DocumentCompleted` | Container for the completion screen after signing |
|
||||
| `.field--FieldRootContainer` | Base container for document fields (signatures, text, checkboxes, etc.) |
|
||||
|
||||
Field components also expose several data attributes that can be used for styling different states:
|
||||
|
||||
| Data Attribute | Values | Description |
|
||||
| ------------------- | ---------------------------------------------- | ------------------------------------ |
|
||||
| `[data-field-type]` | `SIGNATURE`, `TEXT`, `CHECKBOX`, `RADIO`, etc. | The type of field |
|
||||
| `[data-inserted]` | `true`, `false` | Whether the field has been filled |
|
||||
| `[data-validate]` | `true`, `false` | Whether the field is being validated |
|
||||
|
||||
### Field Styling Example
|
||||
|
||||
```css
|
||||
/* Style all field containers */
|
||||
.field--FieldRootContainer {
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
/* Style specific field types */
|
||||
.field--FieldRootContainer[data-field-type='SIGNATURE'] {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Style inserted fields */
|
||||
.field--FieldRootContainer[data-inserted='true'] {
|
||||
background-color: var(--primary);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
/* Style fields being validated */
|
||||
.field--FieldRootContainer[data-validate='true'] {
|
||||
border-color: orange;
|
||||
}
|
||||
```
|
||||
|
||||
### Example Usage
|
||||
|
||||
```css
|
||||
/* Custom styles for the document widget */
|
||||
.embed--DocumentWidget {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Custom styles for the waiting screen */
|
||||
.embed--WaitingForTurn {
|
||||
background-color: #f9fafb;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for the document container */
|
||||
@media (min-width: 768px) {
|
||||
.embed--DocumentContainer {
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [React Integration](/developers/embedding/react)
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -3,8 +3,6 @@ title: Public API
|
||||
description: Learn how to interact with your documents programmatically using the Documenso public API.
|
||||
---
|
||||
|
||||
import { Callout, Steps } from 'nextra/components';
|
||||
|
||||
# Public API
|
||||
|
||||
Documenso provides a public REST API enabling you to interact with your documents programmatically. The API exposes various HTTP endpoints that allow you to perform operations such as:
|
||||
@@ -15,35 +13,10 @@ Documenso provides a public REST API enabling you to interact with your document
|
||||
|
||||
The documentation walks you through creating API keys and using them to authenticate your API requests. You'll also learn about the available endpoints, request and response formats, and how to use the API.
|
||||
|
||||
## API V1 - Stable
|
||||
## Swagger Documentation
|
||||
|
||||
Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
|
||||
## API V2 - Beta
|
||||
|
||||
<Callout type="warning">API V2 is currently beta, and will be subject to breaking changes</Callout>
|
||||
|
||||
Check out the [API V2 documentation](https://documen.so/api-v2-docs) for details about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
|
||||
Our new API V2 supports the following typed SDKs:
|
||||
|
||||
- [TypeScript](https://github.com/documenso/sdk-typescript)
|
||||
- [Python](https://github.com/documenso/sdk-python)
|
||||
- [Go](https://github.com/documenso/sdk-go)
|
||||
|
||||
<Callout type="info">
|
||||
For the staging API, please use the following base URL:
|
||||
`https://stg-app.documenso.dev/api/v2-beta/`
|
||||
</Callout>
|
||||
|
||||
🚀 [V2 Announcement](https://documen.so/sdk-blog)
|
||||
|
||||
📖 [Documentation](https://documen.so/api-v2-docs)
|
||||
|
||||
💬 [Leave Feedback](https://documen.so/sdk-feedback)
|
||||
|
||||
🔔 [Breaking Changes](https://documen.so/sdk-breaking)
|
||||
The [Swagger documentation](https://app.documenso.com/api/v1/openapi) also provides information about the API endpoints, request parameters, response formats, and authentication methods.
|
||||
|
||||
## Availability
|
||||
|
||||
The API is available to individual users, teams and higher plans. [Fair Use](https://documen.so/fair) applies.
|
||||
The API is available to individual users and teams.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ Documenso supports Webhooks and allows you to subscribe to the following events:
|
||||
- `document.signed`
|
||||
- `document.completed`
|
||||
- `document.rejected`
|
||||
- `document.cancelled`
|
||||
|
||||
## Create a webhook subscription
|
||||
|
||||
@@ -38,7 +37,7 @@ Clicking on the "**Create Webhook**" button opens a modal to create a new webhoo
|
||||
To create a new webhook subscription, you need to provide the following information:
|
||||
|
||||
- Enter the webhook URL that will receive the event payload.
|
||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`, `document.cancelled`.
|
||||
- Select the event(s) you want to subscribe to: `document.created`, `document.sent`, `document.opened`, `document.signed`, `document.completed`, `document.rejected`.
|
||||
- Optionally, you can provide a secret key that will be used to sign the payload. This key will be included in the `X-Documenso-Secret` header of the request.
|
||||
|
||||

|
||||
@@ -529,96 +528,6 @@ Example payload for the `document.rejected` event:
|
||||
}
|
||||
```
|
||||
|
||||
Example payload for the `document.rejected` event:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "DOCUMENT_CANCELLED",
|
||||
"payload": {
|
||||
"id": 7,
|
||||
"externalId": null,
|
||||
"userId": 3,
|
||||
"authOptions": null,
|
||||
"formValues": null,
|
||||
"visibility": "EVERYONE",
|
||||
"title": "documenso.pdf",
|
||||
"status": "PENDING",
|
||||
"documentDataId": "cm6exvn93006hi02ru90a265a",
|
||||
"createdAt": "2025-01-27T11:02:14.393Z",
|
||||
"updatedAt": "2025-01-27T11:03:16.387Z",
|
||||
"completedAt": null,
|
||||
"deletedAt": null,
|
||||
"teamId": null,
|
||||
"templateId": null,
|
||||
"source": "DOCUMENT",
|
||||
"documentMeta": {
|
||||
"id": "cm6exvn96006ji02rqvzjvwoy",
|
||||
"subject": "",
|
||||
"message": "",
|
||||
"timezone": "Etc/UTC",
|
||||
"password": null,
|
||||
"dateFormat": "yyyy-MM-dd hh:mm a",
|
||||
"redirectUrl": "",
|
||||
"signingOrder": "PARALLEL",
|
||||
"typedSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": {
|
||||
"documentDeleted": true,
|
||||
"documentPending": true,
|
||||
"recipientSigned": true,
|
||||
"recipientRemoved": true,
|
||||
"documentCompleted": true,
|
||||
"ownerDocumentCompleted": true,
|
||||
"recipientSigningRequest": true
|
||||
}
|
||||
},
|
||||
"recipients": [
|
||||
{
|
||||
"id": 7,
|
||||
"documentId": 7,
|
||||
"templateId": null,
|
||||
"email": "mybirihix@mailinator.com",
|
||||
"name": "Zorita Baird",
|
||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
],
|
||||
"Recipient": [
|
||||
{
|
||||
"id": 7,
|
||||
"documentId": 7,
|
||||
"templateId": null,
|
||||
"email": "signer@documenso.com",
|
||||
"name": "Signer",
|
||||
"token": "XkKx1HCs6Znm2UBJA2j6o",
|
||||
"documentDeletedAt": null,
|
||||
"expired": null,
|
||||
"signedAt": null,
|
||||
"authOptions": { "accessAuth": null, "actionAuth": null },
|
||||
"signingOrder": 1,
|
||||
"rejectionReason": null,
|
||||
"role": "SIGNER",
|
||||
"readStatus": "NOT_OPENED",
|
||||
"signingStatus": "NOT_SIGNED",
|
||||
"sendStatus": "SENT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"createdAt": "2025-01-27T11:03:27.730Z",
|
||||
"webhookEndpoint": "https://mywebhooksite.com/mywebhook"
|
||||
}
|
||||
```
|
||||
|
||||
## Availability
|
||||
|
||||
Webhooks are available to individual users and teams.
|
||||
|
||||
@@ -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,18 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Exit on error.
|
||||
set -e
|
||||
set -eo pipefail
|
||||
|
||||
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
|
||||
WEB_APP_DIR="$SCRIPT_DIR/.."
|
||||
|
||||
# Store the original directory
|
||||
ORIGINAL_DIR=$(pwd)
|
||||
|
||||
# Set up trap to ensure we return to original directory
|
||||
trap 'cd "$ORIGINAL_DIR"' EXIT
|
||||
|
||||
cd "$WEB_APP_DIR"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
start_time=$(date +%s)
|
||||
|
||||
@@ -34,4 +25,4 @@ cp -r ../../packages/lib/translations build/server/hono/packages/lib/translation
|
||||
# Time taken
|
||||
end_time=$(date +%s)
|
||||
|
||||
echo "[Build]: Done in $((end_time - start_time)) seconds"
|
||||
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,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>
|
||||
);
|
||||
};
|
||||
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
@@ -28,6 +28,7 @@ type DocumentDeleteDialogProps = {
|
||||
onDelete?: () => Promise<void> | void;
|
||||
status: DocumentStatus;
|
||||
documentTitle: string;
|
||||
teamId?: number;
|
||||
canManageDocument: boolean;
|
||||
};
|
||||
|
||||
@@ -145,7 +146,7 @@ export const DocumentDeleteDialog = ({
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
))
|
||||
.with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => (
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<AlertDescription>
|
||||
<p>
|
||||
<Trans>By deleting this document, the following will occur:</Trans>
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
type DocumentDuplicateDialogProps = {
|
||||
id: number;
|
||||
@@ -34,7 +34,7 @@ export const DocumentDuplicateDialog = ({
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery(
|
||||
{
|
||||
@@ -97,7 +97,7 @@ export const DocumentDuplicateDialog = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||
<PDFViewer key={document?.id} documentData={documentData} />
|
||||
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type DocumentMoveDialogProps = {
|
||||
documentId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DocumentMoveDialog = ({ documentId, open, onOpenChange }: DocumentMoveDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
|
||||
const { mutateAsync: moveDocument, isPending } = trpc.document.moveDocumentToTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Document moved`),
|
||||
description: _(msg`The document has been successfully moved to the selected team.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: error.message || _(msg`An error occurred while moving the document.`),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onMove = async () => {
|
||||
if (!selectedTeamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await moveDocument({ documentId, teamId: selectedTeamId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Move Document to Team</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Select a team to move this document to. This action cannot be undone.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={_(msg`Select a team`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingTeams ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
<Trans>Loading teams...</Trans>
|
||||
</SelectItem>
|
||||
) : (
|
||||
teams?.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-8 w-8">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||
)}
|
||||
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
|
||||
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
@@ -59,7 +59,7 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
|
||||
|
||||
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
||||
const { user } = useSession();
|
||||
const team = useCurrentTeam();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type ManagePublicTemplateDialogProps = {
|
||||
directTemplates: (Template & {
|
||||
@@ -95,7 +95,7 @@ export const ManagePublicTemplateDialog = ({
|
||||
|
||||
const [open, onOpenChange] = useState(isOpen);
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(initialTemplateId);
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader, TagIcon } from 'lucide-react';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamCheckoutCreateDialogProps = {
|
||||
pendingTeamId: number | null;
|
||||
onClose: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
export const TeamCheckoutCreateDialog = ({
|
||||
pendingTeamId,
|
||||
onClose,
|
||||
...props
|
||||
}: TeamCheckoutCreateDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
|
||||
|
||||
const { data, isLoading } = trpc.team.getTeamPrices.useQuery();
|
||||
|
||||
const { mutateAsync: createCheckout, isPending: isCreatingCheckout } =
|
||||
trpc.team.createTeamPendingCheckout.useMutation({
|
||||
onSuccess: (checkoutUrl) => {
|
||||
window.open(checkoutUrl, '_blank');
|
||||
onClose();
|
||||
},
|
||||
onError: () =>
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We were unable to create a checkout session. Please try again, or contact support`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
}),
|
||||
});
|
||||
|
||||
const selectedPrice = useMemo(() => {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data[interval];
|
||||
}, [data, interval]);
|
||||
|
||||
const handleOnOpenChange = (open: boolean) => {
|
||||
if (pendingTeamId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (pendingTeamId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={pendingTeamId !== null} onOpenChange={handleOnOpenChange}>
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Team checkout</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Payment is required to finalise the creation of your team.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{(isLoading || !data) && (
|
||||
<div className="flex h-20 items-center justify-center text-sm">
|
||||
{isLoading ? (
|
||||
<Loader className="text-documenso h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<p>
|
||||
<Trans>Something went wrong</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && selectedPrice && !isLoading && (
|
||||
<div>
|
||||
<Tabs
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
onValueChange={(value) => setInterval(value as 'monthly' | 'yearly')}
|
||||
value={interval}
|
||||
className="mb-4"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{[data.monthly, data.yearly].map((price) => (
|
||||
<TabsTrigger key={price.priceId} className="w-full" value={price.interval}>
|
||||
{price.friendlyInterval}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<MotionCard
|
||||
key={selectedPrice.priceId}
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||
>
|
||||
<CardContent className="flex h-full flex-col p-6">
|
||||
{selectedPrice.interval === 'monthly' ? (
|
||||
<div className="text-muted-foreground text-lg font-medium">
|
||||
$50 USD <span className="text-xs">per month</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center justify-between text-lg font-medium">
|
||||
<span>
|
||||
$480 USD <span className="text-xs">per year</span>
|
||||
</span>
|
||||
<div className="bg-primary text-primary-foreground ml-2 inline-flex flex-row items-center justify-center rounded px-2 py-1 text-xs">
|
||||
<TagIcon className="mr-1 h-4 w-4" />
|
||||
20% off
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||
<p>
|
||||
<Trans>This price includes minimum 5 seats.</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-1">
|
||||
<Trans>Adding and removing seats will adjust your invoice accordingly.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</MotionCard>
|
||||
</AnimatePresence>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={isCreatingCheckout}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isCreatingCheckout}
|
||||
onClick={async () =>
|
||||
createCheckout({
|
||||
interval: selectedPrice.interval,
|
||||
pendingTeamId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Checkout</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -7,18 +7,15 @@ import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { 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 { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -37,37 +34,29 @@ import {
|
||||
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({
|
||||
const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
|
||||
teamName: true,
|
||||
teamUrl: true,
|
||||
inheritMembers: true,
|
||||
});
|
||||
|
||||
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
||||
|
||||
export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDialogProps) => {
|
||||
export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const navigate = useNavigate();
|
||||
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({
|
||||
@@ -75,25 +64,24 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
defaultValues: {
|
||||
teamName: '',
|
||||
teamUrl: '',
|
||||
inheritMembers: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
|
||||
const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
|
||||
const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => {
|
||||
try {
|
||||
await createTeam({
|
||||
organisationId: organisation.id,
|
||||
const response = await createTeam({
|
||||
teamName,
|
||||
teamUrl,
|
||||
inheritMembers,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
await onCreated?.();
|
||||
await refreshSession();
|
||||
if (response.paymentRequired) {
|
||||
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
@@ -126,28 +114,12 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
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]);
|
||||
}, [actionSearchParam, open, setOpen, updateSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
@@ -173,141 +145,89 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
|
||||
<Trans>Create team</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<DialogDescription className="mt-4">
|
||||
<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"
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<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>
|
||||
<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);
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
const urlField = form.getValues('teamUrl');
|
||||
if (urlField === oldGeneratedUrl) {
|
||||
form.setValue('teamUrl', newGeneratedUrl);
|
||||
}
|
||||
|
||||
{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);
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
const urlField = form.getValues('teamUrl');
|
||||
if (urlField === oldGeneratedUrl) {
|
||||
form.setValue('teamUrl', newGeneratedUrl);
|
||||
}
|
||||
<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>
|
||||
)}
|
||||
|
||||
field.onChange(event);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="dialog-create-team-button"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Create Team</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -36,22 +35,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
export type TeamDeleteDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
redirectTo?: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const TeamDeleteDialog = ({
|
||||
trigger,
|
||||
teamId,
|
||||
teamName,
|
||||
redirectTo,
|
||||
}: TeamDeleteDialogProps) => {
|
||||
export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const deleteMessage = _(msg`delete ${teamName}`);
|
||||
|
||||
@@ -68,23 +60,19 @@ export const TeamDeleteDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
||||
const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
try {
|
||||
await deleteTeam({ teamId });
|
||||
|
||||
await refreshSession();
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Your team has been successfully deleted.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
if (redirectTo) {
|
||||
await navigate(redirectTo);
|
||||
}
|
||||
await navigate('/settings/teams');
|
||||
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
@@ -125,7 +113,7 @@ export const TeamDeleteDialog = ({
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive">
|
||||
<Trans>Delete</Trans>
|
||||
<Trans>Delete team</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -61,12 +61,12 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: sendTeamEmailVerification, isPending } =
|
||||
trpc.team.email.verification.send.useMutation();
|
||||
const { mutateAsync: createTeamEmailVerification, isPending } =
|
||||
trpc.team.createTeamEmailVerification.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
|
||||
try {
|
||||
await sendTeamEmailVerification({
|
||||
await createTeamEmailVerification({
|
||||
teamId,
|
||||
name,
|
||||
email,
|
||||
|
||||
@@ -48,7 +48,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
|
||||
trpc.team.email.delete.useMutation({
|
||||
trpc.team.deleteTeamEmail.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
@@ -67,7 +67,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } =
|
||||
trpc.team.email.verification.delete.useMutation({
|
||||
trpc.team.deleteTeamEmailVerification.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
|
||||
@@ -61,7 +61,7 @@ export const TeamEmailUpdateDialog = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamEmail } = trpc.team.email.update.useMutation();
|
||||
const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
||||
try {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
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 { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
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 TeamLeaveDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
teamAvatarImageId?: string | null;
|
||||
role: TeamMemberRole;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const TeamLeaveDialog = ({
|
||||
trigger,
|
||||
teamId,
|
||||
teamName,
|
||||
teamAvatarImageId,
|
||||
role,
|
||||
}: TeamLeaveDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: leaveTeam, isPending: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`You have successfully left this team.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to leave this team. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLeavingTeam && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="destructive">
|
||||
<Trans>Leave team</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 team.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarSrc={formatAvatarUrl(teamAvatarImageId)}
|
||||
avatarFallback={teamName.slice(0, 1).toUpperCase()}
|
||||
primaryText={teamName}
|
||||
secondaryText={_(TEAM_MEMBER_ROLE_MAP[role])}
|
||||
/>
|
||||
</Alert>
|
||||
|
||||
<fieldset disabled={isLeavingTeam}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isLeavingTeam}
|
||||
onClick={async () => leaveTeam({ teamId })}
|
||||
>
|
||||
<Trans>Leave</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -22,9 +22,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
export type TeamMemberDeleteDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
memberEmail: string;
|
||||
teamMemberId: number;
|
||||
teamMemberName: string;
|
||||
teamMemberEmail: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -32,17 +32,17 @@ export const TeamMemberDeleteDialog = ({
|
||||
trigger,
|
||||
teamId,
|
||||
teamName,
|
||||
memberId,
|
||||
memberName,
|
||||
memberEmail,
|
||||
teamMemberId,
|
||||
teamMemberName,
|
||||
teamMemberEmail,
|
||||
}: TeamMemberDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } =
|
||||
trpc.team.member.delete.useMutation({
|
||||
const { mutateAsync: deleteTeamMembers, isPending: isDeletingTeamMember } =
|
||||
trpc.team.deleteTeamMembers.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
@@ -69,7 +69,7 @@ export const TeamMemberDeleteDialog = ({
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
<Trans>Remove team member</Trans>
|
||||
<Trans>Delete team member</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
@@ -91,9 +91,9 @@ export const TeamMemberDeleteDialog = ({
|
||||
<Alert variant="neutral" padding="tight">
|
||||
<AvatarWithText
|
||||
avatarClass="h-12 w-12"
|
||||
avatarFallback={memberName.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold">{memberName}</span>}
|
||||
secondaryText={memberEmail}
|
||||
avatarFallback={teamMemberName.slice(0, 1).toUpperCase()}
|
||||
primaryText={<span className="font-semibold">{teamMemberName}</span>}
|
||||
secondaryText={teamMemberEmail}
|
||||
/>
|
||||
</Alert>
|
||||
|
||||
@@ -107,9 +107,9 @@ export const TeamMemberDeleteDialog = ({
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeletingTeamMember}
|
||||
onClick={async () => deleteTeamMember({ teamId, memberId })}
|
||||
onClick={async () => deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
+47
-56
@@ -4,7 +4,7 @@ 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 { TeamMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
|
||||
import Papa, { type ParseResult } from 'papaparse';
|
||||
@@ -12,13 +12,9 @@ import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import {
|
||||
ORGANISATION_MEMBER_ROLE_HIERARCHY,
|
||||
ORGANISATION_MEMBER_ROLE_MAP,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
|
||||
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@@ -50,13 +46,15 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type OrganisationMemberInviteDialogProps = {
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TeamMemberInviteDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZInviteOrganisationMembersFormSchema = z
|
||||
const ZInviteTeamMembersFormSchema = z
|
||||
.object({
|
||||
invitations: ZCreateOrganisationMemberInvitesRequestSchema.shape.invitations,
|
||||
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
|
||||
})
|
||||
// Display exactly which rows are duplicates.
|
||||
.superRefine((items, ctx) => {
|
||||
@@ -86,21 +84,18 @@ const ZInviteOrganisationMembersFormSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
type TInviteOrganisationMembersFormSchema = z.infer<typeof ZInviteOrganisationMembersFormSchema>;
|
||||
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
||||
|
||||
type TabTypes = 'INDIVIDUAL' | 'BULK';
|
||||
|
||||
const ZImportOrganisationMemberSchema = z.array(
|
||||
const ZImportTeamMemberSchema = z.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
organisationRole: z.nativeEnum(OrganisationMemberRole),
|
||||
role: z.nativeEnum(TeamMemberRole),
|
||||
}),
|
||||
);
|
||||
|
||||
export const OrganisationMemberInviteDialog = ({
|
||||
trigger,
|
||||
...props
|
||||
}: OrganisationMemberInviteDialogProps) => {
|
||||
export const TeamMemberInviteDialog = ({ trigger, ...props }: TeamMemberInviteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
|
||||
@@ -108,49 +103,48 @@ export const OrganisationMemberInviteDialog = ({
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const form = useForm<TInviteOrganisationMembersFormSchema>({
|
||||
resolver: zodResolver(ZInviteOrganisationMembersFormSchema),
|
||||
const form = useForm<TInviteTeamMembersFormSchema>({
|
||||
resolver: zodResolver(ZInviteTeamMembersFormSchema),
|
||||
defaultValues: {
|
||||
invitations: [
|
||||
{
|
||||
email: '',
|
||||
organisationRole: OrganisationMemberRole.MEMBER,
|
||||
role: TeamMemberRole.MEMBER,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
append: appendOrganisationMemberInvite,
|
||||
fields: organisationMemberInvites,
|
||||
remove: removeOrganisationMemberInvite,
|
||||
append: appendTeamMemberInvite,
|
||||
fields: teamMemberInvites,
|
||||
remove: removeTeamMemberInvite,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: 'invitations',
|
||||
});
|
||||
|
||||
const { mutateAsync: createOrganisationMemberInvites } =
|
||||
trpc.organisation.member.invite.createMany.useMutation();
|
||||
const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation();
|
||||
|
||||
const onAddOrganisationMemberInvite = () => {
|
||||
appendOrganisationMemberInvite({
|
||||
const onAddTeamMemberInvite = () => {
|
||||
appendTeamMemberInvite({
|
||||
email: '',
|
||||
organisationRole: OrganisationMemberRole.MEMBER,
|
||||
role: TeamMemberRole.MEMBER,
|
||||
});
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => {
|
||||
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
|
||||
try {
|
||||
await createOrganisationMemberInvites({
|
||||
organisationId: organisation.id,
|
||||
await createTeamMemberInvites({
|
||||
teamId: team.id,
|
||||
invitations,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`Organisation invitations have been sent.`),
|
||||
description: _(msg`Team invitations have been sent.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -159,7 +153,7 @@ export const OrganisationMemberInviteDialog = ({
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to invite organisation members. Please try again later.`,
|
||||
msg`We encountered an unknown error while attempting to invite team members. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -189,17 +183,17 @@ export const OrganisationMemberInviteDialog = ({
|
||||
|
||||
return {
|
||||
email: email.trim(),
|
||||
organisationRole: role.trim().toUpperCase(),
|
||||
role: role.trim().toUpperCase(),
|
||||
};
|
||||
});
|
||||
|
||||
// Remove the first row if it contains the headers.
|
||||
if (members.length > 1 && members[0].organisationRole.toUpperCase() === 'ROLE') {
|
||||
if (members.length > 1 && members[0].role.toUpperCase() === 'ROLE') {
|
||||
members.shift();
|
||||
}
|
||||
|
||||
try {
|
||||
const importedInvitations = ZImportOrganisationMemberSchema.parse(members);
|
||||
const importedInvitations = ZImportTeamMemberSchema.parse(members);
|
||||
|
||||
form.setValue('invitations', importedInvitations);
|
||||
form.clearErrors('invitations');
|
||||
@@ -235,7 +229,7 @@ export const OrganisationMemberInviteDialog = ({
|
||||
});
|
||||
|
||||
downloadFile({
|
||||
filename: 'documenso-organisation-member-invites-template.csv',
|
||||
filename: 'documenso-team-member-invites-template.csv',
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
@@ -257,7 +251,7 @@ export const OrganisationMemberInviteDialog = ({
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Invite organisation members</Trans>
|
||||
<Trans>Invite team members</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
@@ -290,11 +284,8 @@ export const OrganisationMemberInviteDialog = ({
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
|
||||
{organisationMemberInvites.map((organisationMemberInvite, index) => (
|
||||
<div
|
||||
className="flex w-full flex-row space-x-4"
|
||||
key={organisationMemberInvite.id}
|
||||
>
|
||||
{teamMemberInvites.map((teamMemberInvite, index) => (
|
||||
<div className="flex w-full flex-row space-x-4" key={teamMemberInvite.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.email`}
|
||||
@@ -315,12 +306,12 @@ export const OrganisationMemberInviteDialog = ({
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`invitations.${index}.organisationRole`}
|
||||
name={`invitations.${index}.role`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
{index === 0 && (
|
||||
<FormLabel required>
|
||||
<Trans>Organisation Role</Trans>
|
||||
<Trans>Role</Trans>
|
||||
</FormLabel>
|
||||
)}
|
||||
<FormControl>
|
||||
@@ -330,13 +321,13 @@ export const OrganisationMemberInviteDialog = ({
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
|
||||
organisation.currentOrganisationRole
|
||||
].map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
|
||||
</SelectItem>
|
||||
))}
|
||||
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamMember.role].map(
|
||||
(role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{_(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
@@ -351,8 +342,8 @@ export const OrganisationMemberInviteDialog = ({
|
||||
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
index === 0 ? 'mt-8' : 'mt-0',
|
||||
)}
|
||||
disabled={organisationMemberInvites.length === 1}
|
||||
onClick={() => removeOrganisationMemberInvite(index)}
|
||||
disabled={teamMemberInvites.length === 1}
|
||||
onClick={() => removeTeamMemberInvite(index)}
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -365,7 +356,7 @@ export const OrganisationMemberInviteDialog = ({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-fit"
|
||||
onClick={() => onAddOrganisationMemberInvite()}
|
||||
onClick={() => onAddTeamMemberInvite()}
|
||||
>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
<Trans>Add more</Trans>
|
||||
@@ -43,9 +43,9 @@ export type TeamMemberUpdateDialogProps = {
|
||||
currentUserTeamRole: TeamMemberRole;
|
||||
trigger?: React.ReactNode;
|
||||
teamId: number;
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
memberTeamRole: TeamMemberRole;
|
||||
teamMemberId: number;
|
||||
teamMemberName: string;
|
||||
teamMemberRole: TeamMemberRole;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZUpdateTeamMemberFormSchema = z.object({
|
||||
@@ -58,9 +58,9 @@ export const TeamMemberUpdateDialog = ({
|
||||
currentUserTeamRole,
|
||||
trigger,
|
||||
teamId,
|
||||
memberId,
|
||||
memberName,
|
||||
memberTeamRole,
|
||||
teamMemberId,
|
||||
teamMemberName,
|
||||
teamMemberRole,
|
||||
...props
|
||||
}: TeamMemberUpdateDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -71,17 +71,17 @@ export const TeamMemberUpdateDialog = ({
|
||||
const form = useForm<ZUpdateTeamMemberSchema>({
|
||||
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
|
||||
defaultValues: {
|
||||
role: memberTeamRole,
|
||||
role: teamMemberRole,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamMember } = trpc.team.member.update.useMutation();
|
||||
const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
|
||||
try {
|
||||
await updateTeamMember({
|
||||
teamId,
|
||||
memberId,
|
||||
teamMemberId,
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
@@ -89,7 +89,7 @@ export const TeamMemberUpdateDialog = ({
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`You have updated ${memberName}.`),
|
||||
description: _(msg`You have updated ${teamMemberName}.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ export const TeamMemberUpdateDialog = ({
|
||||
|
||||
form.reset();
|
||||
|
||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
|
||||
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
@@ -121,7 +121,7 @@ export const TeamMemberUpdateDialog = ({
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
|
||||
}, [open, currentUserTeamRole, teamMemberRole, form, toast]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -143,9 +143,9 @@ export const TeamMemberUpdateDialog = ({
|
||||
<Trans>Update team member</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are currently updating <span className="font-bold">{memberName}.</span>
|
||||
You are currently updating <span className="font-bold">{teamMemberName}.</span>
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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 { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
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 { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type TeamTransferDialogProps = {
|
||||
teamId: number;
|
||||
teamName: string;
|
||||
ownerUserId: number;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const TeamTransferDialog = ({
|
||||
trigger,
|
||||
teamId,
|
||||
teamName,
|
||||
ownerUserId,
|
||||
}: TeamTransferDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { mutateAsync: requestTeamOwnershipTransfer } =
|
||||
trpc.team.requestTeamOwnershipTransfer.useMutation();
|
||||
|
||||
const {
|
||||
data,
|
||||
refetch: refetchTeamMembers,
|
||||
isLoading: loadingTeamMembers,
|
||||
isLoadingError: loadingTeamMembersError,
|
||||
} = trpc.team.getTeamMembers.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const confirmTransferMessage = _(msg`transfer ${teamName}`);
|
||||
|
||||
const ZTransferTeamFormSchema = z.object({
|
||||
teamName: z.literal(confirmTransferMessage, {
|
||||
errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
|
||||
}),
|
||||
newOwnerUserId: z.string(),
|
||||
clearPaymentMethods: z.boolean(),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof ZTransferTeamFormSchema>>({
|
||||
resolver: zodResolver(ZTransferTeamFormSchema),
|
||||
defaultValues: {
|
||||
teamName: '',
|
||||
clearPaymentMethods: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({
|
||||
newOwnerUserId,
|
||||
clearPaymentMethods,
|
||||
}: z.infer<typeof ZTransferTeamFormSchema>) => {
|
||||
try {
|
||||
await requestTeamOwnershipTransfer({
|
||||
teamId,
|
||||
newOwnerUserId: Number.parseInt(newOwnerUserId),
|
||||
clearPaymentMethods,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(msg`An email requesting the transfer of this team has been sent.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to request a transfer of this team. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && loadingTeamMembersError) {
|
||||
void refetchTeamMembers();
|
||||
}
|
||||
}, [open, loadingTeamMembersError, refetchTeamMembers]);
|
||||
|
||||
const teamMembers = data
|
||||
? data.filter((teamMember) => teamMember.userId !== ownerUserId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Trans>Transfer team</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
{teamMembers && teamMembers.length > 0 ? (
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Transfer team</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>Transfer ownership of this team to a selected team member.</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="newOwnerUserId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel required>
|
||||
<Trans>New team owner</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
{teamMembers.map((teamMember) => (
|
||||
<SelectItem
|
||||
key={teamMember.userId}
|
||||
value={teamMember.userId.toString()}
|
||||
>
|
||||
{teamMember.user.name} ({teamMember.user.email})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="teamName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing{' '}
|
||||
<span className="text-destructive">{confirmTransferMessage}</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<ul className="list-outside list-disc space-y-2 pl-4">
|
||||
{IS_BILLING_ENABLED() && (
|
||||
<li>
|
||||
<Trans>
|
||||
Any payment methods attached to this team will remain attached to this
|
||||
team. Please contact us if you need to update this information.
|
||||
</Trans>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<Trans>
|
||||
The selected team member will receive an email which they must accept
|
||||
before the team is transferred
|
||||
</Trans>
|
||||
</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
||||
<Trans>Request transfer</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
) : (
|
||||
<DialogContent
|
||||
position="center"
|
||||
className="text-muted-foreground flex items-center justify-center py-16 text-sm"
|
||||
>
|
||||
{loadingTeamMembers ? (
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<p className="text-center text-sm">
|
||||
{loadingTeamMembersError ? (
|
||||
<Trans>An error occurred while loading team members. Please try again later.</Trans>
|
||||
) : (
|
||||
<Trans>You must have at least one other team member to transfer ownership.</Trans>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,274 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { File as FileIcon, Upload, X } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
const ZBulkSendFormSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
sendImmediately: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
|
||||
|
||||
export type TemplateBulkSendDialogProps = {
|
||||
templateId: number;
|
||||
recipients: Array<{ email: string; name?: string | null }>;
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const TemplateBulkSendDialog = ({
|
||||
templateId,
|
||||
recipients,
|
||||
trigger,
|
||||
onSuccess,
|
||||
}: TemplateBulkSendDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const form = useForm<TBulkSendFormSchema>({
|
||||
resolver: zodResolver(ZBulkSendFormSchema),
|
||||
defaultValues: {
|
||||
sendImmediately: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
|
||||
|
||||
const onDownloadTemplate = () => {
|
||||
const headers = recipients.flatMap((_, index) => [
|
||||
`recipient_${index + 1}_email`,
|
||||
`recipient_${index + 1}_name`,
|
||||
]);
|
||||
|
||||
const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
|
||||
|
||||
const csv = [headers.join(','), exampleRow.join(',')].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
const a = Object.assign(document.createElement('a'), {
|
||||
href: url,
|
||||
download: 'template.csv',
|
||||
});
|
||||
|
||||
a.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: TBulkSendFormSchema) => {
|
||||
try {
|
||||
const csv = await values.file.text();
|
||||
|
||||
await uploadBulkSend({
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
csv: csv,
|
||||
sendImmediately: values.sendImmediately,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Success`),
|
||||
description: _(
|
||||
msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
|
||||
),
|
||||
});
|
||||
|
||||
form.reset();
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to upload CSV. Please check the file format and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Bulk Send Template via CSV</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Upload a CSV file to create multiple documents from this template. Each row represents
|
||||
one document with its recipient details.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<div className="bg-muted/70 rounded-lg border p-4">
|
||||
<h3 className="text-sm font-medium">
|
||||
<Trans>CSV Structure</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>
|
||||
For each recipient, provide their email (required) and name (optional) in separate
|
||||
columns. Download the template CSV below for the correct format.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="mt-4 text-sm">
|
||||
<Trans>Current recipients:</Trans>
|
||||
</p>
|
||||
|
||||
<ul className="text-muted-foreground mt-2 list-inside list-disc text-sm">
|
||||
{recipients.map((recipient, index) => (
|
||||
<li key={index}>
|
||||
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Button onClick={onDownloadTemplate} variant="outline" type="button">
|
||||
<Trans>Download Template CSV</Trans>
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Pre-formatted CSV template with example data.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
{!value ? (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<label className="cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange(file);
|
||||
}
|
||||
}}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Upload CSV</Trans>
|
||||
</label>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex h-10 items-center rounded-md border px-3">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<FileIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="flex-1 truncate text-sm">{value.name}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="text-destructive hover:text-destructive p-0 text-xs"
|
||||
onClick={() => onChange(null)}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
<Trans>Remove</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{error && <p className="text-destructive text-sm">{error.message}</p>}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
|
||||
template defaults.
|
||||
</Trans>
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sendImmediately"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="send-immediately"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="send-immediately"
|
||||
className="text-muted-foreground ml-2 flex items-center text-sm"
|
||||
>
|
||||
<Trans>Send documents to recipients immediately</Trans>
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button variant="secondary" onClick={() => form.reset()} type="button">
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Upload and Process</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateCreateDialogProps = {
|
||||
teamId: number;
|
||||
teamId?: number;
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
@@ -83,6 +83,7 @@ export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogP
|
||||
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
{/* Todo: Wouldn't this break for google? */}
|
||||
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>New Template</Trans>
|
||||
|
||||
@@ -76,11 +76,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
);
|
||||
|
||||
const validDirectTemplateRecipients = useMemo(
|
||||
() =>
|
||||
template.recipients.filter(
|
||||
(recipient) =>
|
||||
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
|
||||
),
|
||||
() => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC),
|
||||
[template.recipients],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateMoveDialogProps = {
|
||||
templateId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
onMove?: ({
|
||||
templateId,
|
||||
teamUrl,
|
||||
}: {
|
||||
templateId: number;
|
||||
teamUrl: string;
|
||||
}) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export const TemplateMoveDialog = ({
|
||||
templateId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onMove,
|
||||
}: TemplateMoveDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
|
||||
|
||||
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
|
||||
|
||||
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
|
||||
onSuccess: async () => {
|
||||
const team = teams?.find((team) => team.id === selectedTeamId);
|
||||
|
||||
if (team) {
|
||||
await onMove?.({ templateId, teamUrl: team.url });
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Template moved`),
|
||||
description: _(msg`The template has been successfully moved to the selected team.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AppErrorCode.NOT_FOUND,
|
||||
() => msg`Template not found or already associated with a team.`,
|
||||
)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`)
|
||||
.otherwise(() => msg`An error occurred while moving the template.`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleOnMove = async () => {
|
||||
if (!selectedTeamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await moveTemplate({ templateId, teamId: selectedTeamId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Move Template to Team</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={_(msg`Select a team`)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isLoadingTeams ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
<Trans>Loading teams...</Trans>
|
||||
</SelectItem>
|
||||
) : (
|
||||
teams?.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-8 w-8">
|
||||
{team.avatarImageId && (
|
||||
<AvatarImage src={formatAvatarUrl(team.avatarImageId)} />
|
||||
)}
|
||||
|
||||
<AvatarFallback className="text-sm text-gray-400">
|
||||
{team.name.slice(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span>{team.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOnMove}
|
||||
loading={isPending}
|
||||
disabled={!selectedTeamId || isPending}
|
||||
>
|
||||
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -30,20 +30,22 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TokenDeleteDialogProps = {
|
||||
teamId?: number;
|
||||
token: Pick<ApiToken, 'id' | 'name'>;
|
||||
onDelete?: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function TokenDeleteDialog({ token, onDelete, children }: TokenDeleteDialogProps) {
|
||||
export default function TokenDeleteDialog({
|
||||
teamId,
|
||||
token,
|
||||
onDelete,
|
||||
children,
|
||||
}: TokenDeleteDialogProps) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const deleteMessage = _(msg`delete ${token.name}`);
|
||||
@@ -73,7 +75,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
|
||||
try {
|
||||
await deleteTokenMutation({
|
||||
id: token.id,
|
||||
teamId: team?.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
toast({
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||
import { ZCreateWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -34,11 +34,11 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
|
||||
|
||||
const ZCreateWebhookFormSchema = ZCreateWebhookRequestSchema.omit({ teamId: true });
|
||||
const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true });
|
||||
|
||||
type TCreateWebhookFormSchema = z.infer<typeof ZCreateWebhookFormSchema>;
|
||||
|
||||
@@ -50,7 +50,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -78,7 +78,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
||||
eventTriggers,
|
||||
secret,
|
||||
webhookUrl,
|
||||
teamId: team.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
@@ -230,13 +230,14 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
<div className="flex w-full flex-nowrap gap-4">
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type WebhookDeleteDialogProps = {
|
||||
webhook: Pick<Webhook, 'id' | 'webhookUrl'>;
|
||||
@@ -42,7 +42,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -67,7 +67,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await deleteWebhook({ id: webhook.id, teamId: team.id });
|
||||
await deleteWebhook({ id: webhook.id, teamId: team?.id });
|
||||
|
||||
toast({
|
||||
title: _(msg`Webhook deleted`),
|
||||
|
||||
@@ -8,17 +8,11 @@ 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">
|
||||
@@ -34,15 +28,7 @@ export const EmbedAuthenticationRequired = ({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SignInForm
|
||||
// Embed currently not supported.
|
||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||
// oidcProviderLabel={oidcProviderLabel}
|
||||
className="mt-4"
|
||||
initialEmail={email}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
<SignInForm className="mt-4" initialEmail={email} returnTo={returnTo} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { useSearchParams } from 'react-router';
|
||||
@@ -13,10 +13,6 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import {
|
||||
isFieldUnsignedAndRequired,
|
||||
isRequiredField,
|
||||
} from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type {
|
||||
@@ -25,11 +21,12 @@ import type {
|
||||
} from '@documenso/trpc/server/field-router/schema';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
@@ -50,7 +47,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhiteLabelling?: boolean;
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedDirectTemplateClientPage = ({
|
||||
@@ -61,15 +58,23 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
fields,
|
||||
metadata,
|
||||
hidePoweredBy = false,
|
||||
allowWhiteLabelling = false,
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedDirectTemplateClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const { fullName, email, signature, setFullName, setEmail, setSignature } =
|
||||
useRequiredDocumentSigningContext();
|
||||
const {
|
||||
fullName,
|
||||
email,
|
||||
signature,
|
||||
signatureValid,
|
||||
setFullName,
|
||||
setEmail,
|
||||
setSignature,
|
||||
setSignatureValid,
|
||||
} = useRequiredDocumentSigningContext();
|
||||
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
@@ -87,7 +92,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
|
||||
localFields.filter((field) => !field.inserted),
|
||||
localFields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
@@ -105,7 +110,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
const newField: DirectTemplateLocalField = structuredClone({
|
||||
...field,
|
||||
customText: payload.value ?? '',
|
||||
customText: payload.value,
|
||||
inserted: true,
|
||||
signedValue: payload,
|
||||
});
|
||||
@@ -116,10 +121,8 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
created: new Date(),
|
||||
recipientId: 1,
|
||||
fieldId: 1,
|
||||
signatureImageAsBase64:
|
||||
payload.value && payload.value.startsWith('data:') ? payload.value : null,
|
||||
typedSignature:
|
||||
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
|
||||
signatureImageAsBase64: payload.value.startsWith('data:') ? payload.value : null,
|
||||
typedSignature: payload.value.startsWith('data:') ? null : payload.value,
|
||||
} satisfies Signature;
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
};
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
validateFieldsInserted(pendingFields);
|
||||
validateFieldsInserted(localFields);
|
||||
|
||||
setShowPendingFieldTooltip(true);
|
||||
setIsExpanded(false);
|
||||
@@ -185,7 +188,11 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
const onCompleteClick = async () => {
|
||||
try {
|
||||
const valid = validateFieldsInserted(pendingFields);
|
||||
if (hasSignatureField && !signatureValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = validateFieldsInserted(localFields);
|
||||
|
||||
if (!valid) {
|
||||
setShowPendingFieldTooltip(true);
|
||||
@@ -198,6 +205,12 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||
}
|
||||
|
||||
localFields.forEach((field) => {
|
||||
if (!field.signedValue) {
|
||||
throw new Error('Invalid configuration');
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
documentId,
|
||||
token: documentToken,
|
||||
@@ -208,11 +221,13 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
directRecipientName: fullName,
|
||||
directRecipientEmail: email,
|
||||
templateUpdatedAt: updatedAt,
|
||||
signedFieldValues: localFields
|
||||
.filter((field) => {
|
||||
return field.signedValue && (isRequiredField(field) || field.inserted);
|
||||
})
|
||||
.map((field) => field.signedValue!),
|
||||
signedFieldValues: localFields.map((field) => {
|
||||
if (!field.signedValue) {
|
||||
throw new Error('Invalid configuration');
|
||||
}
|
||||
|
||||
return field.signedValue;
|
||||
}),
|
||||
});
|
||||
|
||||
if (window.parent) {
|
||||
@@ -271,7 +286,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (allowWhiteLabelling) {
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
@@ -323,7 +338,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<PDFViewer
|
||||
<LazyPDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
@@ -332,7 +347,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
|
||||
@@ -400,24 +415,40 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
disableAnimation
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -452,6 +483,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields
|
||||
recipient={recipient}
|
||||
fields={localFields}
|
||||
metadata={metadata}
|
||||
onSignField={onSignField}
|
||||
|
||||
@@ -10,8 +10,9 @@ export type EmbedDocumentCompletedPageProps = {
|
||||
};
|
||||
|
||||
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
|
||||
console.log({ signature });
|
||||
return (
|
||||
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Document Completed!</Trans>
|
||||
</h3>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@prisma/client';
|
||||
import { type Field, FieldType } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -31,6 +31,7 @@ import { DocumentSigningSignatureField } from '~/components/general/document-sig
|
||||
import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field';
|
||||
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
@@ -38,6 +39,7 @@ export type EmbedDocumentFieldsProps = {
|
||||
};
|
||||
|
||||
export const EmbedDocumentFields = ({
|
||||
recipient,
|
||||
fields,
|
||||
metadata,
|
||||
onSignField,
|
||||
@@ -51,17 +53,17 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningSignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
<DocumentSigningInitialsField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -70,6 +72,7 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningNameField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -78,6 +81,7 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningDateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
dateFormat={metadata?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
@@ -88,6 +92,7 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningEmailField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -102,6 +107,7 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningTextField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -117,6 +123,7 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningNumberField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -132,6 +139,7 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningRadioField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -147,6 +155,7 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningCheckboxField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
@@ -162,6 +171,7 @@ export const EmbedDocumentFields = ({
|
||||
<DocumentSigningDropdownField
|
||||
key={field.id}
|
||||
field={fieldWithMeta}
|
||||
recipient={recipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
|
||||
@@ -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,33 +1,24 @@
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
|
||||
import {
|
||||
type DocumentData,
|
||||
type Field,
|
||||
FieldType,
|
||||
RecipientRole,
|
||||
SigningStatus,
|
||||
} from '@prisma/client';
|
||||
import type { DocumentMeta, Recipient, TemplateMeta } from '@prisma/client';
|
||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
|
||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import type { DocumentField } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
@@ -35,26 +26,20 @@ import { injectCss } from '~/utils/css-vars';
|
||||
|
||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
||||
import { DocumentReadOnlyFields } from '../general/document/document-read-only-fields';
|
||||
import { EmbedClientLoading } from './embed-client-loading';
|
||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||
import { EmbedDocumentFields } from './embed-document-fields';
|
||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||
|
||||
export type EmbedSignDocumentClientPageProps = {
|
||||
token: string;
|
||||
documentId: number;
|
||||
documentData: DocumentData;
|
||||
recipient: RecipientWithFields;
|
||||
recipient: Recipient;
|
||||
fields: Field[];
|
||||
completedFields: DocumentField[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
isCompleted?: boolean;
|
||||
hidePoweredBy?: boolean;
|
||||
allowWhitelabelling?: boolean;
|
||||
allRecipients?: RecipientWithFields[];
|
||||
isPlatformOrEnterprise?: boolean;
|
||||
};
|
||||
|
||||
export const EmbedSignDocumentClientPage = ({
|
||||
@@ -63,63 +48,48 @@ export const EmbedSignDocumentClientPage = ({
|
||||
documentData,
|
||||
recipient,
|
||||
fields,
|
||||
completedFields,
|
||||
metadata,
|
||||
isCompleted,
|
||||
hidePoweredBy = false,
|
||||
allowWhitelabelling = false,
|
||||
allRecipients = [],
|
||||
isPlatformOrEnterprise = false,
|
||||
}: EmbedSignDocumentClientPageProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { fullName, email, signature, setFullName, setSignature } =
|
||||
useRequiredDocumentSigningContext();
|
||||
const {
|
||||
fullName,
|
||||
email,
|
||||
signature,
|
||||
signatureValid,
|
||||
setFullName,
|
||||
setSignature,
|
||||
setSignatureValid,
|
||||
} = useRequiredDocumentSigningContext();
|
||||
|
||||
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
||||
const [hasRejectedDocument, setHasRejectedDocument] = useState(
|
||||
recipient.signingStatus === SigningStatus.REJECTED,
|
||||
);
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
||||
allRecipients.length > 0 ? allRecipients[0].id : null,
|
||||
);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||
|
||||
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
||||
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
|
||||
useState(false);
|
||||
|
||||
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||
|
||||
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
||||
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
||||
|
||||
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
||||
|
||||
const [pendingFields, _completedFields] = [
|
||||
fields.filter(
|
||||
(field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field),
|
||||
),
|
||||
fields.filter((field) => !field.inserted),
|
||||
fields.filter((field) => field.inserted),
|
||||
];
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||
|
||||
const fieldsRequiringValidation = useMemo(
|
||||
() => fields.filter(isFieldUnsignedAndRequired),
|
||||
[fields],
|
||||
);
|
||||
|
||||
const hasSignatureField = fields.some((field) => field.type === FieldType.SIGNATURE);
|
||||
|
||||
const assistantSignersId = useId();
|
||||
|
||||
const onNextFieldClick = () => {
|
||||
validateFieldsInserted(fieldsRequiringValidation);
|
||||
validateFieldsInserted(fields);
|
||||
|
||||
setShowPendingFieldTooltip(true);
|
||||
setIsExpanded(false);
|
||||
@@ -127,7 +97,11 @@ export const EmbedSignDocumentClientPage = ({
|
||||
|
||||
const onCompleteClick = async () => {
|
||||
try {
|
||||
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
||||
if (hasSignatureField && !signatureValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = validateFieldsInserted(fields);
|
||||
|
||||
if (!valid) {
|
||||
setShowPendingFieldTooltip(true);
|
||||
@@ -175,25 +149,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onDocumentRejected = (reason: string) => {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-rejected',
|
||||
data: {
|
||||
token,
|
||||
documentId,
|
||||
recipientId: recipient.id,
|
||||
reason,
|
||||
},
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasRejectedDocument(true);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
|
||||
@@ -207,14 +162,12 @@ export const EmbedSignDocumentClientPage = ({
|
||||
// Since a recipient can be provided a name we can lock it without requiring
|
||||
// a to be provided by the parent application, unlike direct templates.
|
||||
setIsNameLocked(!!data.lockName);
|
||||
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
||||
|
||||
if (data.darkModeDisabled) {
|
||||
document.documentElement.classList.add('dark-mode-disabled');
|
||||
}
|
||||
|
||||
if (allowWhitelabelling) {
|
||||
if (isPlatformOrEnterprise) {
|
||||
injectCss({
|
||||
css: data.css,
|
||||
cssVars: data.cssVars,
|
||||
@@ -243,10 +196,6 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
}, [hasFinishedInit, hasDocumentLoaded]);
|
||||
|
||||
if (hasRejectedDocument) {
|
||||
return <EmbedDocumentRejected />;
|
||||
}
|
||||
|
||||
if (hasCompletedDocument) {
|
||||
return (
|
||||
<EmbedDocumentCompleted
|
||||
@@ -264,229 +213,164 @@ export const EmbedSignDocumentClientPage = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||
|
||||
{allowDocumentRejection && (
|
||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||
<DocumentSigningRejectDialog
|
||||
document={{ id: documentId }}
|
||||
token={token}
|
||||
onRejected={onDocumentRejected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="embed--DocumentViewer flex-1">
|
||||
<PDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div className="embed--DocumentWidgetHeader">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
{isAssistantMode ? (
|
||||
<Trans>Assist with signing</Trans>
|
||||
) : (
|
||||
<Trans>Sign document</Trans>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{isAssistantMode ? (
|
||||
<Trans>Help complete the document for other signers.</Trans>
|
||||
) : (
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
{isAssistantMode && (
|
||||
<div>
|
||||
<Label>
|
||||
<Trans>Signing for</Trans>
|
||||
</Label>
|
||||
|
||||
<fieldset className="dark:bg-background border-border mt-2 rounded-2xl border bg-white p-3">
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-3 shadow-none"
|
||||
value={selectedSignerId?.toString()}
|
||||
onValueChange={(value) => setSelectedSignerId(Number(value))}
|
||||
>
|
||||
{allRecipients
|
||||
.filter((r) => r.fields.length > 0)
|
||||
.map((r) => (
|
||||
<div
|
||||
key={`${assistantSignersId}-${r.id}`}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
id={`${assistantSignersId}-${r.id}`}
|
||||
value={r.id.toString()}
|
||||
className="after:absolute after:inset-0"
|
||||
/>
|
||||
|
||||
<div className="grid grow gap-1">
|
||||
<Label
|
||||
className="inline-flex items-start"
|
||||
htmlFor={`${assistantSignersId}-${r.id}`}
|
||||
>
|
||||
{r.name}
|
||||
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{_(msg`(You)`)}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAssistantMode && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
disableAnimation
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||
|
||||
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||
{pendingFields.length > 0 ? (
|
||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
|
||||
disabled={isThrottled}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
||||
|
||||
{/* Completed fields */}
|
||||
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
|
||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||
{/* Viewer */}
|
||||
<div className="flex-1">
|
||||
<LazyPDFViewer
|
||||
documentData={documentData}
|
||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
{/* Widget */}
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit w-full flex-shrink-0 px-6 md:sticky md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
<Trans>Sign document</Trans>
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Sign the document to complete the process.</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
disabled={isNameLocked}
|
||||
value={fullName}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="email">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
id="email"
|
||||
className="bg-background mt-2"
|
||||
value={email}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
onValidityChange={(isValid) => {
|
||||
setSignatureValid(isValid);
|
||||
}}
|
||||
allowTypedSignature={Boolean(
|
||||
metadata &&
|
||||
'typedSignatureEnabled' in metadata &&
|
||||
metadata.typedSignatureEnabled,
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{hasSignatureField && !signatureValid && (
|
||||
<div className="text-destructive mt-2 text-sm">
|
||||
<Trans>
|
||||
Signature is too small. Please provide a more complete signature.
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
||||
|
||||
<div className="mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
||||
{pendingFields.length > 0 ? (
|
||||
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
||||
<Trans>Next</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="col-start-2"
|
||||
disabled={isThrottled || (hasSignatureField && !signatureValid)}
|
||||
loading={isSubmitting}
|
||||
onClick={() => throttledOnCompleteClick()}
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
||||
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
</FieldToolTip>
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
{/* Fields */}
|
||||
<EmbedDocumentFields recipient={recipient} fields={fields} metadata={metadata} />
|
||||
</div>
|
||||
</DocumentSigningRecipientProvider>
|
||||
|
||||
{!hidePoweredBy && (
|
||||
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
|
||||
<span>Powered by</span>
|
||||
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
export const EmbedDocumentWaitingForTurn = () => {
|
||||
const [hasPostedMessage, setHasPostedMessage] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.parent && !hasPostedMessage) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
action: 'document-waiting-for-turn',
|
||||
data: null,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
setHasPostedMessage(true);
|
||||
}, [hasPostedMessage]);
|
||||
|
||||
if (!hasPostedMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||
<h3 className="text-foreground text-center text-2xl font-bold">
|
||||
<Trans>Waiting for Your Turn</Trans>
|
||||
</h3>
|
||||
|
||||
<div className="mt-8 max-w-[50ch] text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
It's currently not your turn to sign. Please check back soon as this document should be
|
||||
available for you to sign shortly.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<Trans>Please check with the parent application for more information.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,10 +6,10 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -42,11 +42,13 @@ export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
|
||||
export const DisableAuthenticatorAppDialog = () => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');
|
||||
|
||||
const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
|
||||
|
||||
const disable2FAForm = useForm<TDisable2FAForm>({
|
||||
defaultValues: {
|
||||
totpCode: '',
|
||||
@@ -79,7 +81,7 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
|
||||
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
|
||||
try {
|
||||
await authClient.twoFactor.disable({ totpCode, backupCode });
|
||||
await disable2FA({ totpCode, backupCode });
|
||||
|
||||
toast({
|
||||
title: _(msg`Two-factor authentication disabled`),
|
||||
@@ -92,7 +94,7 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
onCloseTwoFactorDisableDialog();
|
||||
});
|
||||
|
||||
await refreshSession();
|
||||
await revalidate();
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: _(msg`Unable to disable two-factor authentication`),
|
||||
|
||||
@@ -5,12 +5,12 @@ 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 { renderSVG } from 'uqr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -48,12 +48,28 @@ export type EnableAuthenticatorAppDialogProps = {
|
||||
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [isSettingUp2FA, setIsSettingUp2FA] = useState(false);
|
||||
const [setup2FAData, setSetup2FAData] = useState<{ uri: string; secret: string } | null>(null);
|
||||
|
||||
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const {
|
||||
mutateAsync: setup2FA,
|
||||
data: setup2FAData,
|
||||
isPending: isSettingUp2FA,
|
||||
} = trpc.twoFactorAuthentication.setup.useMutation({
|
||||
onError: () => {
|
||||
toast({
|
||||
title: _(msg`Unable to setup two-factor authentication`),
|
||||
description: _(
|
||||
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const enable2FAForm = useForm<TEnable2FAForm>({
|
||||
defaultValues: {
|
||||
@@ -64,36 +80,9 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
|
||||
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
|
||||
|
||||
const setup2FA = async () => {
|
||||
if (isSettingUp2FA) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSettingUp2FA(true);
|
||||
setSetup2FAData(null);
|
||||
|
||||
try {
|
||||
const data = await authClient.twoFactor.setup();
|
||||
await refreshSession();
|
||||
|
||||
setSetup2FAData(data);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Unable to setup two-factor authentication`),
|
||||
description: _(
|
||||
msg`We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
setIsSettingUp2FA(false);
|
||||
};
|
||||
|
||||
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
||||
try {
|
||||
const data = await authClient.twoFactor.enable({ code: token });
|
||||
await refreshSession();
|
||||
const data = await enable2FA({ code: token });
|
||||
|
||||
setRecoveryCodes(data.recoveryCodes);
|
||||
onSuccess?.();
|
||||
@@ -141,6 +130,7 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
|
||||
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||
setRecoveryCodes(null);
|
||||
void revalidate();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -6,9 +6,9 @@ import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -41,32 +41,20 @@ export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
||||
export const ViewRecoveryCodesDialog = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const {
|
||||
data: recoveryCodes,
|
||||
mutate,
|
||||
isPending,
|
||||
error,
|
||||
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
|
||||
const form = useForm<TViewRecoveryCodesForm>({
|
||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ token }: TViewRecoveryCodesForm) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await authClient.twoFactor.viewRecoveryCodes({
|
||||
token,
|
||||
});
|
||||
|
||||
setRecoveryCodes(data.backupCodes);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
setError(error.code);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (recoveryCodes) {
|
||||
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||
@@ -118,8 +106,8 @@ export const ViewRecoveryCodesDialog = () => {
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<Form {...viewRecoveryCodesForm}>
|
||||
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>
|
||||
<Trans>View Recovery Codes</Trans>
|
||||
@@ -130,10 +118,10 @@ export const ViewRecoveryCodesDialog = () => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset className="flex flex-col space-y-4" disabled={form.formState.isSubmitting}>
|
||||
<fieldset className="flex flex-col space-y-4" disabled={isPending}>
|
||||
<FormField
|
||||
name="token"
|
||||
control={form.control}
|
||||
control={viewRecoveryCodesForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
@@ -173,7 +161,7 @@ export const ViewRecoveryCodesDialog = () => {
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Trans>View</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -42,7 +42,7 @@ export type AvatarImageFormProps = {
|
||||
};
|
||||
|
||||
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
|
||||
const { user, refreshSession } = useSession();
|
||||
const { user } = useSession();
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { revalidate } = useRevalidator();
|
||||
@@ -103,13 +103,13 @@ export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
await refreshSession();
|
||||
|
||||
toast({
|
||||
title: _(msg`Avatar Updated`),
|
||||
description: _(msg`Your avatar has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
void revalidate();
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -122,7 +122,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>New Password</Trans>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="new-password" {...field} />
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { z } from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
@@ -19,15 +20,12 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZProfileFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
|
||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||
});
|
||||
|
||||
export const ZTwoFactorAuthTokenSchema = z.object({
|
||||
@@ -44,7 +42,8 @@ export type ProfileFormProps = {
|
||||
export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user, refreshSession } = useSession();
|
||||
const { user } = useSession();
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const form = useForm<TProfileFormSchema>({
|
||||
values: {
|
||||
@@ -65,13 +64,13 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
signature,
|
||||
});
|
||||
|
||||
await refreshSession();
|
||||
|
||||
toast({
|
||||
title: _(msg`Profile updated`),
|
||||
description: _(msg`Your profile has been updated successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await revalidate();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
@@ -112,20 +111,22 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
</Label>
|
||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signature"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
render={({ field: { onChange } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Signature</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<SignaturePadDialog
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
value={value}
|
||||
containerClassName={cn('rounded-lg border bg-background')}
|
||||
defaultValue={user.signature ?? undefined}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
allowTypedSignature={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -135,7 +136,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
</fieldset>
|
||||
|
||||
<Button type="submit" loading={isSubmitting} className="self-end">
|
||||
<Trans>Update profile</Trans>
|
||||
{isSubmitting ? <Trans>Updating profile...</Trans> : <Trans>Update profile</Trans>}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import type { User } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import profileClaimTeaserImage from '@documenso/assets/images/profile-claim-teaser.png';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { UserProfileSkeleton } from '../general/user-profile-skeleton';
|
||||
|
||||
export const ZClaimPublicProfileFormSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(1, { message: 'Please enter a valid username.' })
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Username can only container alphanumeric characters and dashes.',
|
||||
}),
|
||||
});
|
||||
|
||||
export type TClaimPublicProfileFormSchema = z.infer<typeof ZClaimPublicProfileFormSchema>;
|
||||
|
||||
export type ClaimPublicProfileDialogFormProps = {
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onClaimed?: () => void;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const ClaimPublicProfileDialogForm = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onClaimed,
|
||||
user,
|
||||
}: ClaimPublicProfileDialogFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [claimed, setClaimed] = useState(false);
|
||||
|
||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
||||
|
||||
const form = useForm<TClaimPublicProfileFormSchema>({
|
||||
values: {
|
||||
url: user.url || '',
|
||||
},
|
||||
resolver: zodResolver(ZClaimPublicProfileFormSchema),
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onFormSubmit = async ({ url }: TClaimPublicProfileFormSchema) => {
|
||||
try {
|
||||
await updatePublicProfile({
|
||||
url,
|
||||
});
|
||||
|
||||
setClaimed(true);
|
||||
onClaimed?.();
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === 'PROFILE_URL_TAKEN') {
|
||||
form.setError('url', {
|
||||
type: 'manual',
|
||||
message: _(msg`This username is already taken`),
|
||||
});
|
||||
} else if (error.code === 'PREMIUM_PROFILE_URL') {
|
||||
form.setError('url', {
|
||||
type: 'manual',
|
||||
message: error.message,
|
||||
});
|
||||
} else if (error.code !== AppErrorCode.UNKNOWN_ERROR) {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
description: error.userMessage ?? error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
description: _(
|
||||
msg`We encountered an unknown error while attempting to save your details. Please try again later.`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent position="center" className="max-w-lg overflow-hidden">
|
||||
{!claimed && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-semi-bold text-center text-xl">
|
||||
Introducing public profiles!
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="text-center">
|
||||
Reserve your Documenso public profile username
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<img src={profileClaimTeaserImage} alt="profile claim teaser" />
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={cn(
|
||||
'to-background -mt-32 flex w-full flex-col bg-gradient-to-b from-transparent to-15% pt-16 md:-mt-44',
|
||||
)}
|
||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||
>
|
||||
<fieldset className="-mt-6 flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Public profile username</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="text" className="mb-2 mt-2" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase">
|
||||
{baseUrl.host}/u/{field.value || '<username>'}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Claim your username
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{claimed && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-semi-bold text-center text-xl">All set!</DialogTitle>
|
||||
|
||||
<DialogDescription className="text-center">
|
||||
We will let you know as soon as this features is launched
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<UserProfileSkeleton className="mt-4" user={user} rows={1} />
|
||||
|
||||
<div className="to-background -mt-12 flex w-full flex-col items-center bg-gradient-to-b from-transparent to-15% px-4 pt-8 md:-mt-12">
|
||||
<Button className="w-full" onClick={() => onOpenChange?.(false)}>
|
||||
Can't wait!
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -16,8 +16,8 @@ import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
|
||||
import {
|
||||
MAX_PROFILE_BIO_LENGTH,
|
||||
ZUpdateTeamPublicProfileMutationSchema,
|
||||
} from '@documenso/trpc/server/team-router/schema';
|
||||
ZUpdatePublicProfileMutationSchema,
|
||||
} from '@documenso/trpc/server/profile-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -32,7 +32,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZPublicProfileFormSchema = ZUpdateTeamPublicProfileMutationSchema.pick({
|
||||
export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({
|
||||
bio: true,
|
||||
enabled: true,
|
||||
url: true,
|
||||
@@ -43,7 +43,7 @@ export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
|
||||
export type PublicProfileFormProps = {
|
||||
className?: string;
|
||||
profileUrl?: string | null;
|
||||
teamUrl: string;
|
||||
teamUrl?: string;
|
||||
onProfileUpdate: (data: TPublicProfileFormSchema) => Promise<unknown>;
|
||||
profile: UserProfile | TeamProfile;
|
||||
};
|
||||
|
||||
@@ -55,7 +55,7 @@ const handleFallbackErrorMessages = (code: string) => {
|
||||
return message;
|
||||
};
|
||||
|
||||
const LOGIN_REDIRECT_PATH = '/dashboard';
|
||||
const LOGIN_REDIRECT_PATH = '/documents';
|
||||
|
||||
export const ZSignInFormSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
@@ -257,9 +257,7 @@ export const SignInForm = ({
|
||||
|
||||
const onSignInWithGoogleClick = async () => {
|
||||
try {
|
||||
await authClient.google.signIn({
|
||||
redirectPath,
|
||||
});
|
||||
await authClient.google.signIn();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
@@ -273,9 +271,11 @@ export const SignInForm = ({
|
||||
|
||||
const onSignInWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn({
|
||||
redirectPath,
|
||||
});
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
// await signIn('oidc', {
|
||||
// callbackUrl,
|
||||
// });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } 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 { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FaIdCardClip } from 'react-icons/fa6';
|
||||
import { FcGoogle } from 'react-icons/fc';
|
||||
@@ -14,6 +15,7 @@ import { z } from 'zod';
|
||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -28,11 +30,16 @@ import {
|
||||
} 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 { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { UserProfileSkeleton } from '~/components/general/user-profile-skeleton';
|
||||
import { UserProfileTimur } from '~/components/general/user-profile-timur';
|
||||
|
||||
const SIGN_UP_REDIRECT_PATH = '/documents';
|
||||
|
||||
type SignUpStep = 'BASIC_DETAILS' | 'CLAIM_USERNAME';
|
||||
|
||||
export const ZSignUpFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
@@ -42,6 +49,14 @@ export const ZSignUpFormSchema = z
|
||||
email: z.string().email().min(1),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(1, { message: msg`We need a username to create your profile`.id })
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: msg`Username can only container alphanumeric characters and dashes.`.id,
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -58,6 +73,8 @@ 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.`,
|
||||
PROFILE_URL_TAKEN: msg`This username has already been taken`,
|
||||
PREMIUM_PROFILE_URL: msg`Only subscribers can have a username shorter than 6 characters`,
|
||||
};
|
||||
|
||||
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||
@@ -82,14 +99,19 @@ export const SignUpForm = ({
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [step, setStep] = useState<SignUpStep>('BASIC_DETAILS');
|
||||
|
||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||
|
||||
const baseUrl = new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000');
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
email: initialEmail ?? '',
|
||||
password: '',
|
||||
signature: '',
|
||||
url: '',
|
||||
},
|
||||
mode: 'onBlur',
|
||||
resolver: zodResolver(ZSignUpFormSchema),
|
||||
@@ -97,13 +119,17 @@ export const SignUpForm = ({
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
|
||||
const name = form.watch('name');
|
||||
const url = form.watch('url');
|
||||
|
||||
const onFormSubmit = async ({ name, email, password, signature, url }: TSignUpFormSchema) => {
|
||||
try {
|
||||
await authClient.emailPassword.signUp({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
signature,
|
||||
url,
|
||||
});
|
||||
|
||||
await navigate(`/unverified-account`);
|
||||
@@ -126,11 +152,26 @@ export const SignUpForm = ({
|
||||
|
||||
const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST;
|
||||
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (error.code === 'PROFILE_URL_TAKEN' || error.code === 'PREMIUM_PROFILE_URL') {
|
||||
form.setError('url', {
|
||||
type: 'manual',
|
||||
message: _(errorMessage),
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: _(msg`An error occurred`),
|
||||
description: _(errorMessage),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onNextClick = async () => {
|
||||
const valid = await form.trigger(['name', 'email', 'password', 'signature']);
|
||||
|
||||
if (valid) {
|
||||
setStep('CLAIM_USERNAME');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -150,7 +191,9 @@ export const SignUpForm = ({
|
||||
|
||||
const onSignUpWithOIDCClick = async () => {
|
||||
try {
|
||||
await authClient.oidc.signIn();
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
// await signIn('oidc', { callbackUrl: SIGN_UP_REDIRECT_PATH });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`An unknown error occurred`),
|
||||
@@ -192,30 +235,59 @@ export const SignUpForm = ({
|
||||
<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>
|
||||
<AnimatePresence>
|
||||
{step === 'BASIC_DETAILS' ? (
|
||||
<motion.div className="w-full max-w-md" layoutId="user-profile">
|
||||
<UserProfileTimur
|
||||
rows={2}
|
||||
className="bg-background border-border rounded-2xl border shadow-md"
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div className="w-full max-w-md" layoutId="user-profile">
|
||||
<UserProfileSkeleton
|
||||
user={{ name, url }}
|
||||
rows={2}
|
||||
className="bg-background border-border rounded-2xl border shadow-md"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<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>
|
||||
{step === 'BASIC_DETAILS' && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{step === 'CLAIM_USERNAME' && (
|
||||
<div className="h-20">
|
||||
<h1 className="text-xl font-semibold md:text-2xl">
|
||||
<Trans>Claim your username now</Trans>
|
||||
</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-xs md:text-sm">
|
||||
<Trans>
|
||||
You will get notified & be able to set up your documenso public profile when we
|
||||
launch the feature.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="-mx-6 my-4" />
|
||||
|
||||
@@ -224,170 +296,245 @@ export const SignUpForm = ({
|
||||
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>
|
||||
{step === 'BASIC_DETAILS' && (
|
||||
<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="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 } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Sign Here</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<SignaturePad
|
||||
className="h-36 w-full"
|
||||
disabled={isSubmitting}
|
||||
containerClassName="mt-2 rounded-lg border bg-background"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{step === 'CLAIM_USERNAME' && (
|
||||
<fieldset
|
||||
className={cn(
|
||||
'flex h-[550px] w-full flex-col gap-y-4',
|
||||
isGoogleSSOEnabled && 'h-[650px]',
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Public profile username</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input type="text" className="mb-2 mt-2 lowercase" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
<div className="bg-muted/50 border-border text-muted-foreground mt-2 inline-block max-w-[16rem] truncate rounded-md border px-2 py-1 text-sm lowercase">
|
||||
{baseUrl.host}/u/{field.value || '<username>'}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
{step === 'BASIC_DETAILS' && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<span className="font-medium">
|
||||
<Trans>Basic details</Trans>
|
||||
</span>{' '}
|
||||
1/2
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</>
|
||||
{step === 'CLAIM_USERNAME' && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<span className="font-medium">
|
||||
<Trans>Claim username</Trans>
|
||||
</span>{' '}
|
||||
2/2
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</>
|
||||
<div className="bg-foreground/40 relative mt-4 h-1.5 rounded-full">
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="bg-documenso absolute inset-y-0 left-0 rounded-full"
|
||||
style={{
|
||||
width: step === 'BASIC_DETAILS' ? '50%' : '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
{/* Go back button, disabled if step is basic details */}
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
disabled={step === 'BASIC_DETAILS' || form.formState.isSubmitting}
|
||||
onClick={() => setStep('BASIC_DETAILS')}
|
||||
>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
|
||||
{/* Continue button */}
|
||||
{step === 'BASIC_DETAILS' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="flex-1 disabled:cursor-not-allowed"
|
||||
loading={form.formState.isSubmitting}
|
||||
onClick={onNextClick}
|
||||
>
|
||||
<Trans>Next</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>
|
||||
{/* Sign up button */}
|
||||
{step === 'CLAIM_USERNAME' && (
|
||||
<Button
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={!form.formState.isValid}
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="flex-1"
|
||||
>
|
||||
<Trans>Complete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+66
-22
@@ -1,14 +1,17 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import type { Team, TeamGlobalSettings } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -22,11 +25,12 @@ import {
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { Textarea } from '@documenso/ui/primitives/textarea';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
const ZBrandingPreferencesFormSchema = z.object({
|
||||
const ZTeamBrandingPreferencesFormSchema = z.object({
|
||||
brandingEnabled: z.boolean(),
|
||||
brandingLogo: z
|
||||
.instanceof(File)
|
||||
@@ -40,36 +44,74 @@ const ZBrandingPreferencesFormSchema = z.object({
|
||||
brandingCompanyDetails: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export type TBrandingPreferencesFormSchema = z.infer<typeof ZBrandingPreferencesFormSchema>;
|
||||
type TTeamBrandingPreferencesFormSchema = z.infer<typeof ZTeamBrandingPreferencesFormSchema>;
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
TeamGlobalSettings,
|
||||
'brandingEnabled' | 'brandingLogo' | 'brandingUrl' | 'brandingCompanyDetails'
|
||||
>;
|
||||
|
||||
export type BrandingPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
onFormSubmit: (data: TBrandingPreferencesFormSchema) => Promise<void>;
|
||||
export type TeamBrandingPreferencesFormProps = {
|
||||
team: Team;
|
||||
settings?: TeamGlobalSettings | null;
|
||||
};
|
||||
|
||||
export function BrandingPreferencesForm({ settings, onFormSubmit }: BrandingPreferencesFormProps) {
|
||||
const { t } = useLingui();
|
||||
export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
|
||||
|
||||
const form = useForm<TBrandingPreferencesFormSchema>({
|
||||
const { mutateAsync: updateTeamBrandingSettings } =
|
||||
trpc.team.updateTeamBrandingSettings.useMutation();
|
||||
|
||||
const form = useForm<TTeamBrandingPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
brandingEnabled: settings?.brandingEnabled ?? false,
|
||||
brandingUrl: settings?.brandingUrl ?? '',
|
||||
brandingLogo: undefined,
|
||||
brandingCompanyDetails: settings?.brandingCompanyDetails ?? '',
|
||||
},
|
||||
resolver: zodResolver(ZBrandingPreferencesFormSchema),
|
||||
resolver: zodResolver(ZTeamBrandingPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const isBrandingEnabled = form.watch('brandingEnabled');
|
||||
|
||||
const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
|
||||
let uploadedBrandingLogo = settings?.brandingLogo;
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateTeamBrandingSettings({
|
||||
teamId: team.id,
|
||||
settings: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo,
|
||||
brandingUrl,
|
||||
brandingCompanyDetails,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Branding preferences updated`),
|
||||
description: _(msg`Your branding preferences have been updated`),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(
|
||||
msg`We were unable to update your branding preferences at this time, please try again later`,
|
||||
),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (settings?.brandingLogo) {
|
||||
const file = JSON.parse(settings.brandingLogo);
|
||||
@@ -100,8 +142,11 @@ export function BrandingPreferencesForm({ settings, onFormSubmit }: BrandingPref
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col gap-y-4" disabled={form.formState.isSubmitting}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl flex-col gap-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandingEnabled"
|
||||
@@ -147,8 +192,7 @@ export function BrandingPreferencesForm({ settings, onFormSubmit }: BrandingPref
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/20 dark:bg-muted text-muted-foreground relative flex h-full w-full items-center justify-center text-sm">
|
||||
<Trans>Please upload a logo</Trans>
|
||||
|
||||
Please upload a logo
|
||||
{!hasLoadedPreview && (
|
||||
<div className="bg-muted dark:bg-muted absolute inset-0 z-[999] flex items-center justify-center">
|
||||
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
@@ -247,7 +291,7 @@ export function BrandingPreferencesForm({ settings, onFormSubmit }: BrandingPref
|
||||
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t`Enter your brand details`}
|
||||
placeholder={_(msg`Enter your brand details`)}
|
||||
className="min-h-[100px] resize-y"
|
||||
disabled={!isBrandingEnabled}
|
||||
{...field}
|
||||
@@ -264,7 +308,7 @@ export function BrandingPreferencesForm({ settings, onFormSubmit }: BrandingPref
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -0,0 +1,311 @@
|
||||
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 { Team, TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ZTeamDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES),
|
||||
includeSenderDetails: z.boolean(),
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
includeSigningCertificate: z.boolean(),
|
||||
});
|
||||
|
||||
type TTeamDocumentPreferencesFormSchema = z.infer<typeof ZTeamDocumentPreferencesFormSchema>;
|
||||
|
||||
export type TeamDocumentPreferencesFormProps = {
|
||||
team: Team;
|
||||
settings?: TeamGlobalSettings | null;
|
||||
};
|
||||
|
||||
export const TeamDocumentPreferencesForm = ({
|
||||
team,
|
||||
settings,
|
||||
}: TeamDocumentPreferencesFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
|
||||
const placeholderEmail = user.email ?? 'user@example.com';
|
||||
|
||||
const { mutateAsync: updateTeamDocumentPreferences } =
|
||||
trpc.team.updateTeamDocumentSettings.useMutation();
|
||||
|
||||
const form = useForm<TTeamDocumentPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
documentVisibility: settings?.documentVisibility ?? 'EVERYONE',
|
||||
documentLanguage: isValidLanguageCode(settings?.documentLanguage)
|
||||
? settings?.documentLanguage
|
||||
: 'en',
|
||||
includeSenderDetails: settings?.includeSenderDetails ?? false,
|
||||
typedSignatureEnabled: settings?.typedSignatureEnabled ?? true,
|
||||
includeSigningCertificate: settings?.includeSigningCertificate ?? true,
|
||||
},
|
||||
resolver: zodResolver(ZTeamDocumentPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const includeSenderDetails = form.watch('includeSenderDetails');
|
||||
|
||||
const onSubmit = async (data: TTeamDocumentPreferencesFormSchema) => {
|
||||
try {
|
||||
const {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
typedSignatureEnabled,
|
||||
} = data;
|
||||
|
||||
await updateTeamDocumentPreferences({
|
||||
teamId: team.id,
|
||||
settings: {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
typedSignatureEnabled,
|
||||
includeSigningCertificate,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Document preferences updated`),
|
||||
description: _(msg`Your document preferences have been updated`),
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: _(msg`Something went wrong!`),
|
||||
description: _(
|
||||
msg`We were unable to update your document preferences at this time, please try again later`,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-xl 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} onValueChange={field.onChange}>
|
||||
<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>
|
||||
</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} onValueChange={field.onChange}>
|
||||
<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>
|
||||
))}
|
||||
</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="includeSenderDetails"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Send on Behalf of Team</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{includeSenderDetails ? (
|
||||
<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="typedSignatureEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Enable Typed Signature</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the recipients can sign the documents using a typed signature.
|
||||
Enable or disable the typed signature globally.
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<FormControl className="block">
|
||||
<Switch
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<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>Save</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ import type { z } from 'zod';
|
||||
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 { ZUpdateTeamRequestSchema } from '@documenso/trpc/server/team-router/update-team.types';
|
||||
import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
@@ -29,7 +29,7 @@ export type UpdateTeamDialogProps = {
|
||||
teamUrl: string;
|
||||
};
|
||||
|
||||
const ZTeamUpdateFormSchema = ZUpdateTeamRequestSchema.shape.data.pick({
|
||||
const ZTeamUpdateFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
|
||||
name: true,
|
||||
url: true,
|
||||
});
|
||||
@@ -49,7 +49,7 @@ export const TeamUpdateForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeam } = trpc.team.update.useMutation();
|
||||
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ name, url }: TTeamUpdateFormSchema) => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -38,8 +38,6 @@ import {
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export const EXPIRATION_DATES = {
|
||||
ONE_WEEK: msg`7 days`,
|
||||
ONE_MONTH: msg`1 month`,
|
||||
@@ -61,13 +59,14 @@ type NewlyCreatedToken = {
|
||||
|
||||
export type ApiTokenFormProps = {
|
||||
className?: string;
|
||||
teamId?: number;
|
||||
tokens?: Pick<ApiToken, 'id'>[];
|
||||
};
|
||||
|
||||
export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
export const ApiTokenForm = ({ className, teamId, tokens }: ApiTokenFormProps) => {
|
||||
const [isTransitionPending, startTransition] = useTransition();
|
||||
|
||||
const team = useOptionalCurrentTeam();
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -114,7 +113,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
|
||||
try {
|
||||
await createTokenMutation({
|
||||
teamId: team?.id,
|
||||
teamId,
|
||||
tokenName,
|
||||
expirationDate: noExpirationDate ? null : expirationDate,
|
||||
});
|
||||
@@ -239,7 +238,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
type="submit"
|
||||
className="hidden md:inline-flex"
|
||||
disabled={!form.formState.isDirty}
|
||||
loading={form.formState.isSubmitting}
|
||||
loading={form.formState.isSubmitting || isTransitionPending}
|
||||
>
|
||||
<Trans>Create token</Trans>
|
||||
</Button>
|
||||
@@ -248,7 +247,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.formState.isDirty}
|
||||
loading={form.formState.isSubmitting}
|
||||
loading={form.formState.isSubmitting || isTransitionPending}
|
||||
>
|
||||
<Trans>Create token</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||
const { data: searchDocumentsData, isLoading: isSearchingDocuments } =
|
||||
trpcReact.document.searchDocuments.useQuery(
|
||||
{
|
||||
query: search,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user