Compare commits
67 Commits
feat/teams
...
feat/accep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
536adf6e0a | ||
|
|
be3ab09738 | ||
|
|
1c4a5449bb | ||
|
|
144bd4782b | ||
|
|
857e35c10a | ||
|
|
e9d6c24137 | ||
|
|
dd5f39205a | ||
|
|
f6ce7be61f | ||
|
|
9979d32a56 | ||
|
|
daa541d570 | ||
|
|
1d91a9e813 | ||
|
|
560352492d | ||
|
|
075e15d428 | ||
|
|
27e5ef0a51 | ||
|
|
84b0c2756b | ||
|
|
58b3a127ea | ||
|
|
7e71e06e04 | ||
|
|
0b8e84b6b7 | ||
|
|
e17e4566cd | ||
|
|
d73ef57794 | ||
|
|
b09071ebc7 | ||
|
|
66bb56047a | ||
|
|
f9d26e6b3f | ||
|
|
3054d84ba7 | ||
|
|
a71078cbd5 | ||
|
|
4fd6a0d5b6 | ||
|
|
fface15a22 | ||
|
|
6be119ac95 | ||
|
|
b8a45dd5e3 | ||
|
|
5660b99df7 | ||
|
|
8c5216cd44 | ||
|
|
e646c4cf08 | ||
|
|
5d56b152d6 | ||
|
|
5c16b10dc2 | ||
|
|
9bcd6e39e7 | ||
|
|
2202fa3d04 | ||
|
|
c1a6a327af | ||
|
|
837c17f1f3 | ||
|
|
d731532fbf | ||
|
|
d02f6774b2 | ||
|
|
77facba8b4 | ||
|
|
e900706ab0 | ||
|
|
bed788f78f | ||
|
|
341481d6db | ||
|
|
8f5634268d | ||
|
|
5d6f69dc19 | ||
|
|
5307fa6453 | ||
|
|
0bb86963a9 | ||
|
|
fb0d9b8ef9 | ||
|
|
918c6f19f2 | ||
|
|
3f89f8725b | ||
|
|
c4800f74b9 | ||
|
|
d8eff192fe | ||
|
|
eb84d7ff3c | ||
|
|
32633f96d2 | ||
|
|
27b7e29be7 | ||
|
|
de4a0b2560 | ||
|
|
5a11de1db9 | ||
|
|
8d1b960aa8 | ||
|
|
2b25806c33 | ||
|
|
6d58e60a65 | ||
|
|
dd56836121 | ||
|
|
775a1b774d | ||
|
|
c949c4701b | ||
|
|
eda635e2db | ||
|
|
2056de2e16 | ||
|
|
6ad3edb6c8 |
@@ -77,8 +77,6 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
|||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
|
||||||
NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID=
|
|
||||||
|
|
||||||
# [[FEATURES]]
|
# [[FEATURES]]
|
||||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||||
|
|||||||
9
.github/workflows/stale.yml
vendored
9
.github/workflows/stale.yml
vendored
@@ -15,11 +15,10 @@ jobs:
|
|||||||
- uses: actions/stale@v4
|
- uses: actions/stale@v4
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
days-before-pr-stale: 30
|
days-before-pr-stale: 90
|
||||||
days-before-issue-stale: 30
|
days-before-issue-stale: 90
|
||||||
stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected'
|
days-before-issue-close: 180
|
||||||
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
|
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
|
||||||
close-issue-message: 'This issue has been closed because of inactivity.'
|
|
||||||
close-pr-message: 'This PR has been closed because of inactivity.'
|
close-pr-message: 'This PR has been closed because of inactivity.'
|
||||||
exempt-pr-labels: 'WIP,on-hold,needs review'
|
exempt-pr-labels: 'WIP,on-hold,needs review'
|
||||||
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned'
|
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -13,9 +13,9 @@
|
|||||||
·
|
·
|
||||||
<a href="https://github.com/documenso/documenso/issues">Issues</a>
|
<a href="https://github.com/documenso/documenso/issues">Issues</a>
|
||||||
·
|
·
|
||||||
<a href="https://github.com/documenso/documenso/milestones">Roadmap</a>
|
<a href="https://documen.so/live">Upcoming Releases</a>
|
||||||
·
|
·
|
||||||
<a href="https://documen.so/launches">Upcoming Launches</a>
|
<a href="https://documen.so/roadmap">Roadmap</a>
|
||||||
</p>
|
</p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -115,10 +115,12 @@ To run Documenso locally, you will need
|
|||||||
|
|
||||||
Want to get up and running quickly? Follow these steps:
|
Want to get up and running quickly? Follow these steps:
|
||||||
|
|
||||||
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
|
||||||
|
|
||||||
|
After forking the repository, clone it to your local device by using the following command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/documenso/documenso
|
git clone https://github.com/<your-username>/documenso
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults.
|
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults.
|
||||||
@@ -152,10 +154,12 @@ npm run d
|
|||||||
|
|
||||||
Follow these steps to setup Documenso on your local machine:
|
Follow these steps to setup Documenso on your local machine:
|
||||||
|
|
||||||
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
|
||||||
|
|
||||||
|
After forking the repository, clone it to your local device by using the following command:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/documenso/documenso
|
git clone https://github.com/<your-username>/documenso
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Run `npm i` in the root directory
|
2. Run `npm i` in the root directory
|
||||||
@@ -280,12 +284,16 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### Railway
|
### Railway
|
||||||
|
|
||||||
[](https://railway.app/template/DjrRRX)
|
[](https://railway.app/template/bG6D4p)
|
||||||
|
|
||||||
### Render
|
### Render
|
||||||
|
|
||||||
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
[](https://render.com/deploy?repo=https://github.com/documenso/documenso)
|
||||||
|
|
||||||
|
### Koyeb
|
||||||
|
|
||||||
|
[](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### I'm not receiving any emails when using the developer quickstart.
|
### I'm not receiving any emails when using the developer quickstart.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Announcing Pre-Seed and Open Metrics
|
title: Announcing Pre-Seed and Open Metrics
|
||||||
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
|
||||||
authorName: 'Timur Ercan'
|
authorName: 'Timur Ercan'
|
||||||
authorImage: '/blog/blog-author-timur.jpeg'
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
authorRole: 'Co-Founder'
|
authorRole: 'Co-Founder'
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
|
|||||||
|
|
||||||
## Documenso Merch Shop
|
## Documenso Merch Shop
|
||||||
|
|
||||||
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso.
|
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<MdxNextImage
|
<MdxNextImage
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"recharts": "^2.7.2",
|
"recharts": "^2.7.2",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.33.1",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<div className={cn('border-t py-12', className)} {...props}>
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
||||||
<div>
|
<div className="flex-shrink-0">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Image
|
<Image
|
||||||
src={LogoImage}
|
src={LogoImage}
|
||||||
@@ -64,13 +64,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2">
|
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||||
{FOOTER_LINKS.map((link, index) => (
|
{FOOTER_LINKS.map((link, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
target={link.target}
|
target={link.target}
|
||||||
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm"
|
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm"
|
||||||
>
|
>
|
||||||
{link.text}
|
{link.text}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { HTMLAttributes, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
@@ -90,10 +91,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (step === STEP.EMAIL) {
|
if (step === STEP.EMAIL) {
|
||||||
return 1;
|
return 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 3;
|
return 1;
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
const onNextStepClick = () => {
|
const onNextStepClick = () => {
|
||||||
@@ -354,6 +355,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
|
className="absolute inset-x-0 bottom-0 flex cursor-auto items-center justify-between px-4 pb-2"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
id="signatureText"
|
id="signatureText"
|
||||||
@@ -391,10 +393,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
By signing you signal your support of Documenso's mission in a <br></br>
|
By signing you signal your support of Documenso's mission in a <br />
|
||||||
<strong>non-legally binding, but heartfelt way</strong>. <br></br>
|
<strong>non-legally binding, but heartfelt way</strong>. <br />
|
||||||
<br></br>You also unlock the option to purchase the early supporter plan including
|
<br />
|
||||||
everything we build this year for fixed price.
|
You also unlock the option to purchase the early supporter plan including everything we
|
||||||
|
build this year for fixed price.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
import { buffer } from 'micro';
|
import { buffer } from 'micro';
|
||||||
@@ -6,7 +6,8 @@ import { buffer } from 'micro';
|
|||||||
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
|
||||||
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
|
||||||
import { redis } from '@documenso/lib/server-only/redis';
|
import { redis } from '@documenso/lib/server-only/redis';
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FONT_DANCING_SCRIPT_BYTES = fs.readFileSync(
|
||||||
|
path.join(__dirname, '../../packages/assets/fonts/dancing-script.ttf'),
|
||||||
|
);
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
@@ -40,6 +44,7 @@ const config = {
|
|||||||
APP_VERSION: version,
|
APP_VERSION: version,
|
||||||
NEXT_PUBLIC_PROJECT: 'web',
|
NEXT_PUBLIC_PROJECT: 'web',
|
||||||
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
|
||||||
|
FONT_DANCING_SCRIPT_URI: `data:font/ttf;base64,${FONT_DANCING_SCRIPT_BYTES.toString('base64')}`,
|
||||||
},
|
},
|
||||||
modularizeImports: {
|
modularizeImports: {
|
||||||
'lucide-react': {
|
'lucide-react': {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.33.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
|
|||||||
1
apps/web/process-env.d.ts
vendored
1
apps/web/process-env.d.ts
vendored
@@ -6,7 +6,6 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_DATABASE_URL: string;
|
NEXT_PRIVATE_DATABASE_URL: string;
|
||||||
|
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||||
NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID?: string;
|
|
||||||
|
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -7,9 +7,9 @@ import Link from 'next/link';
|
|||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Document, User } from '@documenso/prisma/client';
|
import { Document, User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
accessorKey: 'owner',
|
accessorKey: 'owner',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const avatarFallbackText = row.original.User.name
|
const avatarFallbackText = row.original.User.name
|
||||||
? extractInitials(row.original.User.name)
|
? recipientInitials(row.original.User.name)
|
||||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { RoleCombobox } from './role-combobox';
|
|
||||||
|
|
||||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||||
|
|
||||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
@@ -118,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RoleCombobox
|
<MultiSelectCombobox
|
||||||
listValues={roles}
|
listValues={roles}
|
||||||
onChange={(values: string[]) => onChange(values)}
|
onChange={(values: string[]) => onChange(values)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
|
||||||
import type { Team } from '@documenso/prisma/client';
|
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
|
|
||||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
export type DocumentPageComponentProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
team?: Team;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function DocumentPageComponent({ params, team }: DocumentPageComponentProps) {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
const documentRootPath = team ? `/t/${team.url}/documents` : '/documents';
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const document = await getDocumentById({
|
|
||||||
id: documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
|
||||||
redirect(documentRootPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { documentData } = document;
|
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
|
||||||
await getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
await getFieldsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<div className="text-muted-foreground flex items-center">
|
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
|
||||||
</StackAvatarsWithTooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
|
||||||
<EditDocumentForm
|
|
||||||
className="mt-8"
|
|
||||||
document={document}
|
|
||||||
user={user}
|
|
||||||
recipients={recipients}
|
|
||||||
fields={fields}
|
|
||||||
documentData={documentData}
|
|
||||||
documentRootPath={documentRootPath}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
|
||||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
|
||||||
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -31,7 +31,6 @@ export type EditDocumentFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
documentRootPath: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||||
@@ -44,7 +43,6 @@ export const EditDocumentForm = ({
|
|||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
documentData,
|
documentData,
|
||||||
documentRootPath,
|
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -147,14 +145,16 @@ export const EditDocumentForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
const { subject, message } = data.email;
|
const { subject, message, timezone, dateFormat } = data.meta;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendDocument({
|
await sendDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
email: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
|
timezone,
|
||||||
|
dateFormat,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ export const EditDocumentForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(documentRootPath);
|
router.push('/documents');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
import DocumentPageComponent from './document-page-component';
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@@ -6,6 +20,80 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentPage({ params }: DocumentPageProps) {
|
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||||
return <DocumentPageComponent params={params} />;
|
const { id } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: documentId,
|
||||||
|
userId: user.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document || !document.documentData) {
|
||||||
|
redirect('/documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { documentData } = document;
|
||||||
|
|
||||||
|
const [recipients, fields] = await Promise.all([
|
||||||
|
getRecipientsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
getFieldsForDocument({
|
||||||
|
documentId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
Documents
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
|
{document.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||||
|
|
||||||
|
{recipients.length > 0 && (
|
||||||
|
<div className="text-muted-foreground flex items-center">
|
||||||
|
<Users2 className="mr-2 h-5 w-5" />
|
||||||
|
|
||||||
|
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||||
|
<span>{recipients.length} Recipient(s)</span>
|
||||||
|
</StackAvatarsWithTooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||||
|
<EditDocumentForm
|
||||||
|
className="mt-8"
|
||||||
|
document={document}
|
||||||
|
user={user}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
documentData={documentData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||||
|
<div className="mx-auto mt-12 max-w-2xl">
|
||||||
|
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
@@ -20,10 +19,9 @@ export type DataTableActionButtonProps = {
|
|||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
};
|
};
|
||||||
teamUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionButton = ({ row, teamUrl }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -40,8 +38,6 @@ export const DataTableActionButton = ({ row, teamUrl }: DataTableActionButtonPro
|
|||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(teamUrl);
|
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
@@ -96,7 +92,7 @@ export const DataTableActionButton = ({ row, teamUrl }: DataTableActionButtonPro
|
|||||||
})
|
})
|
||||||
.with({ isOwner: true, isDraft: true }, () => (
|
.with({ isOwner: true, isDraft: true }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}`}>
|
<Link href={`/documents/${row.id}`}>
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
@@ -41,10 +40,9 @@ export type DataTableActionDropdownProps = {
|
|||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
};
|
};
|
||||||
teamUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row, teamUrl }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@@ -64,8 +62,6 @@ export const DataTableActionDropdown = ({ row, teamUrl }: DataTableActionDropdow
|
|||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isDocumentDeletable = isOwner;
|
const isDocumentDeletable = isOwner;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(teamUrl);
|
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
let document: DocumentWithData | null = null;
|
let document: DocumentWithData | null = null;
|
||||||
|
|
||||||
@@ -121,7 +117,7 @@ export const DataTableActionDropdown = ({ row, teamUrl }: DataTableActionDropdow
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||||
<Link href={`${documentsPath}/${row.id}`}>
|
<Link href={`/documents/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
@@ -179,7 +175,6 @@ export const DataTableActionDropdown = ({ row, teamUrl }: DataTableActionDropdow
|
|||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
teamUrl={teamUrl}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
|
||||||
import { parseToNumberArray } from '@documenso/lib/utils/params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
|
||||||
|
|
||||||
type DataTableSenderFilterProps = {
|
|
||||||
teamId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const isMounted = useIsMounted();
|
|
||||||
|
|
||||||
const senderIds = parseToNumberArray(searchParams?.get('senderIds') ?? '');
|
|
||||||
|
|
||||||
const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
|
|
||||||
teamId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const comboBoxOptions = (data ?? []).map((member) => ({
|
|
||||||
label: member.user.name ?? member.user.email,
|
|
||||||
value: member.user.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const onChange = (newSenderIds: number[]) => {
|
|
||||||
if (!pathname) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams?.toString());
|
|
||||||
|
|
||||||
params.set('senderIds', newSenderIds.join(','));
|
|
||||||
|
|
||||||
if (newSenderIds.length === 0) {
|
|
||||||
params.delete('senderIds');
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Combobox
|
|
||||||
emptySelectionPlaceholder={
|
|
||||||
<p className="text-muted-foreground font-normal">
|
|
||||||
<span className="text-muted-foreground/70">Sender:</span> All
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
enableClearAllButton={true}
|
|
||||||
inputPlaceholder="Search"
|
|
||||||
loading={!isMounted || isInitialLoading}
|
|
||||||
options={comboBoxOptions}
|
|
||||||
selectedValues={senderIds}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -27,15 +27,9 @@ export type DocumentsDataTableProps = {
|
|||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
showSenderColumn?: boolean;
|
|
||||||
teamUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({
|
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||||
results,
|
|
||||||
showSenderColumn,
|
|
||||||
teamUrl,
|
|
||||||
}: DocumentsDataTableProps) => {
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -67,11 +61,6 @@ export const DocumentsDataTable = ({
|
|||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'sender',
|
|
||||||
header: 'Sender',
|
|
||||||
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
accessorKey: 'recipient',
|
accessorKey: 'recipient',
|
||||||
@@ -90,8 +79,8 @@ export const DocumentsDataTable = ({
|
|||||||
(!row.original.deletedAt ||
|
(!row.original.deletedAt ||
|
||||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DataTableActionButton teamUrl={teamUrl} row={row.original} />
|
<DataTableActionButton row={row.original} />
|
||||||
<DataTableActionDropdown teamUrl={teamUrl} row={row.original} />
|
<DataTableActionDropdown row={row.original} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -101,9 +90,6 @@ export const DocumentsDataTable = ({
|
|||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
columnVisibility={{
|
|
||||||
sender: Boolean(showSenderColumn),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
|
||||||
import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats';
|
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
|
||||||
import { parseToNumberArray } from '@documenso/lib/utils/params';
|
|
||||||
import type { Team, TeamEmail } from '@documenso/prisma/client';
|
|
||||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
|
||||||
import {
|
|
||||||
type PeriodSelectorValue,
|
|
||||||
isPeriodSelectorValue,
|
|
||||||
} from '~/components/(dashboard)/period-selector/types';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
|
||||||
import { DataTableSenderFilter } from './data-table-sender-filter';
|
|
||||||
import { EmptyDocumentState } from './empty-state';
|
|
||||||
import { UploadDocument } from './upload-document';
|
|
||||||
|
|
||||||
export type DocumentsPageComponentProps = {
|
|
||||||
searchParams?: {
|
|
||||||
status?: ExtendedDocumentStatus;
|
|
||||||
period?: PeriodSelectorValue;
|
|
||||||
page?: string;
|
|
||||||
perPage?: string;
|
|
||||||
senderIds?: string;
|
|
||||||
};
|
|
||||||
team?: Team & { teamEmail?: TeamEmail | null };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function DocumentsPageComponent({
|
|
||||||
searchParams = {},
|
|
||||||
team,
|
|
||||||
}: DocumentsPageComponentProps) {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
|
||||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
|
||||||
const page = Number(searchParams.page) || 1;
|
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
|
||||||
const documentsPath = team ? `/t/${team.url}/documents` : '/documents';
|
|
||||||
const senderIds = parseToNumberArray(searchParams.senderIds ?? '');
|
|
||||||
|
|
||||||
let teamStatOptions: GetStatsInput['team'] = undefined;
|
|
||||||
|
|
||||||
if (team) {
|
|
||||||
teamStatOptions = {
|
|
||||||
teamId: team.id,
|
|
||||||
teamEmail: team.teamEmail?.email,
|
|
||||||
senderIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await getStats({
|
|
||||||
user,
|
|
||||||
team: teamStatOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await findDocuments({
|
|
||||||
userId: user.id,
|
|
||||||
teamId: team?.id,
|
|
||||||
status,
|
|
||||||
orderBy: {
|
|
||||||
column: 'createdAt',
|
|
||||||
direction: 'desc',
|
|
||||||
},
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
period,
|
|
||||||
senderIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
const getTabHref = (value: typeof status) => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
|
|
||||||
params.set('status', value);
|
|
||||||
|
|
||||||
if (params.has('page')) {
|
|
||||||
params.delete('page');
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${documentsPath}?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<UploadDocument team={team ? { id: team.id, url: team.url } : undefined} />
|
|
||||||
|
|
||||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
{team && (
|
|
||||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
|
||||||
<AvatarFallback className="text-xs text-gray-400">
|
|
||||||
{team.name.slice(0, 1)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
|
||||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
|
||||||
<TabsList>
|
|
||||||
{[
|
|
||||||
ExtendedDocumentStatus.INBOX,
|
|
||||||
ExtendedDocumentStatus.PENDING,
|
|
||||||
ExtendedDocumentStatus.COMPLETED,
|
|
||||||
ExtendedDocumentStatus.DRAFT,
|
|
||||||
ExtendedDocumentStatus.ALL,
|
|
||||||
].map((value) => (
|
|
||||||
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
|
|
||||||
<Link href={getTabHref(value)} scroll={false}>
|
|
||||||
<DocumentStatus status={value} />
|
|
||||||
|
|
||||||
{value !== ExtendedDocumentStatus.ALL && (
|
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">
|
|
||||||
{Math.min(stats[value], 99)}
|
|
||||||
{stats[value] > 99 && '+'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
))}
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{team && <DataTableSenderFilter teamId={team.id} />}
|
|
||||||
|
|
||||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
|
||||||
<PeriodSelector />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
{results.count > 0 && (
|
|
||||||
<DocumentsDataTable
|
|
||||||
results={results}
|
|
||||||
showSenderColumn={team !== undefined}
|
|
||||||
teamUrl={team?.url}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@@ -17,14 +16,12 @@ type DuplicateDocumentDialogProps = {
|
|||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
teamUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicateDocumentDialog = ({
|
export const DuplicateDocumentDialog = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
teamUrl,
|
|
||||||
}: DuplicateDocumentDialogProps) => {
|
}: DuplicateDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -40,12 +37,10 @@ export const DuplicateDocumentDialog = ({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(teamUrl);
|
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`${documentsPath}/${newId}`);
|
router.push(`/documents/${newId}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
|
|||||||
@@ -1,10 +1,114 @@
|
|||||||
import type { DocumentsPageComponentProps } from './documents-page-component';
|
import Link from 'next/link';
|
||||||
import DocumentsPageComponent from './documents-page-component';
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
|
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||||
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||||
|
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
|
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
|
import { DocumentsDataTable } from './data-table';
|
||||||
|
import { EmptyDocumentState } from './empty-state';
|
||||||
|
import { UploadDocument } from './upload-document';
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: DocumentsPageComponentProps['searchParams'];
|
searchParams?: {
|
||||||
|
status?: ExtendedDocumentStatus;
|
||||||
|
period?: PeriodSelectorValue;
|
||||||
|
page?: string;
|
||||||
|
perPage?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
return <DocumentsPageComponent searchParams={searchParams} />;
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const stats = await getStats({
|
||||||
|
user,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||||
|
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||||
|
const page = Number(searchParams.page) || 1;
|
||||||
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
|
|
||||||
|
const results = await findDocuments({
|
||||||
|
userId: user.id,
|
||||||
|
status,
|
||||||
|
orderBy: {
|
||||||
|
column: 'createdAt',
|
||||||
|
direction: 'desc',
|
||||||
|
},
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
period,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTabHref = (value: typeof status) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
|
||||||
|
params.set('status', value);
|
||||||
|
|
||||||
|
if (params.has('page')) {
|
||||||
|
params.delete('page');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/documents?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<UploadDocument />
|
||||||
|
|
||||||
|
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||||
|
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
|
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||||
|
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||||
|
<TabsList>
|
||||||
|
{[
|
||||||
|
ExtendedDocumentStatus.INBOX,
|
||||||
|
ExtendedDocumentStatus.PENDING,
|
||||||
|
ExtendedDocumentStatus.COMPLETED,
|
||||||
|
ExtendedDocumentStatus.DRAFT,
|
||||||
|
ExtendedDocumentStatus.ALL,
|
||||||
|
].map((value) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={value}
|
||||||
|
className="hover:text-foreground min-w-[60px]"
|
||||||
|
value={value}
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={getTabHref(value)} scroll={false}>
|
||||||
|
<DocumentStatus status={value} />
|
||||||
|
|
||||||
|
{value !== ExtendedDocumentStatus.ALL && (
|
||||||
|
<span className="ml-1 hidden opacity-50 md:inline-block">
|
||||||
|
{Math.min(stats[value], 99)}
|
||||||
|
{stats[value] > 99 && '+'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||||
|
<PeriodSelector />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||||
|
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -20,15 +20,12 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
team?: {
|
|
||||||
id: number;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -39,6 +36,16 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
|
||||||
|
|
||||||
|
const disabledMessage = useMemo(() => {
|
||||||
|
if (remaining.documents === 0) {
|
||||||
|
return 'You have reached your document limit.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session?.user.emailVerified) {
|
||||||
|
return 'Verify your email to upload documents.';
|
||||||
|
}
|
||||||
|
}, [remaining.documents, session?.user.emailVerified]);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -53,7 +60,6 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
const { id } = await createDocument({
|
const { id } = await createDocument({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
teamId: team?.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -68,7 +74,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(team?.id !== undefined ? `/t/${team.url}/documents/${id}` : `/documents/${id}`);
|
router.push(`/documents/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
@@ -95,17 +101,16 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
className="min-h-[40vh]"
|
className="min-h-[40vh]"
|
||||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute -bottom-6 right-0">
|
<div className="absolute -bottom-6 right-0">
|
||||||
{team?.id === undefined &&
|
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||||
remaining.documents > 0 &&
|
<p className="text-muted-foreground/60 text-xs">
|
||||||
Number.isFinite(remaining.documents) && (
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
</p>
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
)}
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -114,7 +119,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{team?.id === undefined && remaining.documents === 0 && (
|
{remaining.documents === 0 && (
|
||||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
@@ -27,17 +26,13 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ user }, teams] = await Promise.all([
|
const { user } = await getRequiredServerComponentSession();
|
||||||
getRequiredServerComponentSession(),
|
|
||||||
getTeams({ userId: session.user.id }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
<Header user={user} />
|
||||||
<Header user={user} teams={teams} />
|
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { createBillingPortal } from './create-billing-portal.action';
|
import { createBillingPortal } from './create-billing-portal.action';
|
||||||
|
|
||||||
export type BillingPortalButtonProps = {
|
export const BillingPortalButton = () => {
|
||||||
buttonProps?: React.ComponentProps<typeof Button>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||||
@@ -52,11 +48,7 @@ export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
||||||
{...buttonProps}
|
|
||||||
onClick={async () => handleFetchPortalUrl()}
|
|
||||||
loading={isFetchingPortalUrl}
|
|
||||||
>
|
|
||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type AcceptTeamInvitationButtonProps = {
|
|
||||||
teamId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AcceptTeamInvitationButton = ({ teamId }: AcceptTeamInvitationButtonProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutateAsync: acceptTeamInvitation,
|
|
||||||
isLoading,
|
|
||||||
isSuccess,
|
|
||||||
} = trpc.team.acceptTeamInvitation.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Accepted team invitation',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description: 'Unable to join this team at this time.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
onClick={async () => acceptTeamInvitation({ teamId })}
|
|
||||||
loading={isLoading}
|
|
||||||
disabled={isLoading || isSuccess}
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
|
|
||||||
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import CreateTeamDialog from '~/components/(teams)/dialogs/create-team-dialog';
|
|
||||||
import UserTeamsPageDataTable from '~/components/(teams)/tables/user-teams-page-data-table';
|
|
||||||
|
|
||||||
import TeamEmailUsage from './team-email-usage';
|
|
||||||
import { TeamInvitations } from './team-invitations';
|
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
|
||||||
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader title="Teams" subtitle="Manage all teams you are currently associated with.">
|
|
||||||
<CreateTeamDialog />
|
|
||||||
</SettingsHeader>
|
|
||||||
|
|
||||||
<UserTeamsPageDataTable />
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{teamEmail && (
|
|
||||||
<motion.section
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TeamEmailUsage teamEmail={teamEmail} />
|
|
||||||
</motion.section>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<TeamInvitations />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import type { TeamEmail } from '@documenso/prisma/client';
|
|
||||||
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TeamEmailUsageProps = {
|
|
||||||
teamEmail: TeamEmail & { team: { name: string; url: string } };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TeamEmailUsage({ teamEmail }: TeamEmailUsageProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
|
||||||
trpc.team.deleteTeamEmail.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'You have successfully revoked access.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to revoke access. Please try again or contact support.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-8 flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
|
|
||||||
<div className="text-sm">
|
|
||||||
<h3 className="text-base font-medium">Team email</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Your email is currently being used by team{' '}
|
|
||||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
|
|
||||||
).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-1">They have permission on your behalf to:</p>
|
|
||||||
|
|
||||||
<ul className="text-muted-foreground mt-0.5 list-inside list-disc">
|
|
||||||
<li>Display your name and email in documents</li>
|
|
||||||
<li>View all documents sent to your account</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="destructive">Revoke access</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Are you sure?</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
You are about to revoke access for team{' '}
|
|
||||||
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}) to
|
|
||||||
use your email.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<fieldset disabled={isDeletingTeamEmail}>
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDeletingTeamEmail}
|
|
||||||
onClick={async () => deleteTeamEmail({ teamId: teamEmail.teamId })}
|
|
||||||
>
|
|
||||||
Revoke
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { BellIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dialog';
|
|
||||||
|
|
||||||
import { AcceptTeamInvitationButton } from './accept-team-invitation-button';
|
|
||||||
|
|
||||||
export const TeamInvitations = () => {
|
|
||||||
const { data, isInitialLoading } = trpc.team.getTeamInvitations.useQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{data && data.length > 0 && !isInitialLoading && (
|
|
||||||
<motion.div
|
|
||||||
className="mt-8 flex flex-row items-center justify-between rounded-md bg-blue-50 p-6"
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Todo: Teams - Extract into `Alerts` component? */}
|
|
||||||
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
|
||||||
|
|
||||||
<div className="text-sm text-blue-700">
|
|
||||||
You have <strong>{data.length}</strong> pending team invitation
|
|
||||||
{data.length > 1 ? 's' : ''}.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
|
||||||
View invites
|
|
||||||
</button>
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Pending invitations</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
|
|
||||||
{data.map((invitation) => (
|
|
||||||
<li key={invitation.teamId}>
|
|
||||||
<AvatarWithText
|
|
||||||
className="w-full max-w-none py-4"
|
|
||||||
avatarFallback={invitation.team.name.slice(0, 1)}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">
|
|
||||||
{invitation.team.name}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
secondaryText={formatTeamUrl(invitation.team.url)}
|
|
||||||
rightSideComponent={
|
|
||||||
<div className="ml-auto">
|
|
||||||
<AcceptTeamInvitationButton teamId={invitation.team.id} />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -15,6 +15,8 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume
|
|||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -36,6 +38,8 @@ export default async function CompletedSigningPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
const { documentData } = document;
|
const { documentData } = document;
|
||||||
|
|
||||||
const [fields, recipient] = await Promise.all([
|
const [fields, recipient] = await Promise.all([
|
||||||
@@ -89,7 +93,7 @@ export default async function CompletedSigningPage({
|
|||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
You have signed
|
You have signed
|
||||||
<span className="mt-1.5 block">"{document.title}"</span>
|
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import {
|
||||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
convertToLocalSystemFormat,
|
||||||
|
} from '@documenso/lib/constants/date-formats';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -16,9 +21,16 @@ import { SigningFieldContainer } from './signing-field-container';
|
|||||||
export type DateFieldProps = {
|
export type DateFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
|
dateFormat?: string | null;
|
||||||
|
timezone?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DateField = ({ field, recipient }: DateFieldProps) => {
|
export const DateField = ({
|
||||||
|
field,
|
||||||
|
recipient,
|
||||||
|
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||||
|
}: DateFieldProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -35,12 +47,18 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
|||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
|
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
|
||||||
|
|
||||||
|
const isDifferentTime = field.inserted && localDateString !== field.customText;
|
||||||
|
|
||||||
|
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
||||||
|
|
||||||
const onSign = async () => {
|
const onSign = async () => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: '',
|
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
});
|
});
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
@@ -75,7 +93,13 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Date"
|
||||||
|
tooltipText={isDifferentTime ? tooltipText : undefined}
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@@ -87,7 +111,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && (
|
{field.inserted && (
|
||||||
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
|
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
|
||||||
)}
|
)}
|
||||||
</SigningFieldContainer>
|
</SigningFieldContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import { useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { useElementScaleSize } from '@documenso/lib/client-only/hooks/use-element-scale-size';
|
||||||
|
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
|
||||||
|
import {
|
||||||
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
||||||
|
MIN_HANDWRITING_FONT_SIZE,
|
||||||
|
} from '@documenso/lib/constants/pdf';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -28,26 +36,67 @@ export type SigningFormProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningForm = ({ document, recipient, fields }: SigningFormProps) => {
|
const ZSigningpadSchema = z.union([
|
||||||
|
z.object({
|
||||||
|
signatureDataUrl: z.string().min(1),
|
||||||
|
signatureText: z.null().or(z.string().max(0)),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
signatureDataUrl: z.null().or(z.string().max(0)),
|
||||||
|
signatureText: z.string().trim().min(1),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type TSigningpadSchema = z.infer<typeof ZSigningpadSchema>;
|
||||||
|
|
||||||
|
export const SigningForm = ({ document: _document, recipient, fields }: SigningFormProps) => {
|
||||||
|
const fontVariable = '--font-signature';
|
||||||
|
const minFontSize = MIN_HANDWRITING_FONT_SIZE;
|
||||||
|
const maxFontSize = DEFAULT_HANDWRITING_FONT_SIZE;
|
||||||
|
const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue(
|
||||||
|
fontVariable,
|
||||||
|
);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||||
|
|
||||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||||
|
|
||||||
const { mutateAsync: completeDocumentWithToken } =
|
const { mutateAsync: completeDocumentWithToken } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = useForm();
|
} = useForm<TSigningpadSchema>({
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: {
|
||||||
|
signatureDataUrl: signature || null,
|
||||||
|
signatureText: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZSigningpadSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { height, width } = useFieldPageCoords(fields.find((field) => field.type === 'SIGNATURE')!);
|
||||||
|
|
||||||
|
const signatureDataUrl = watch('signatureDataUrl');
|
||||||
|
const signatureText = watch('signatureText');
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
const uninsertedFields = useMemo(() => {
|
||||||
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
|
const fieldsValidated = () => {
|
||||||
|
setValidateUninsertedFields(true);
|
||||||
|
validateFieldsInserted(fields);
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
@@ -59,18 +108,30 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
|
|
||||||
await completeDocumentWithToken({
|
await completeDocumentWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: _document.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
documentId: document.id,
|
documentId: _document.id,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`/sign/${recipient.token}/complete`);
|
router.push(`/sign/${recipient.token}/complete`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scalingFactor = useElementScaleSize(
|
||||||
|
{
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
},
|
||||||
|
signatureText || '',
|
||||||
|
maxFontSize,
|
||||||
|
fontVariableValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fontSize = maxFontSize * scalingFactor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -92,7 +153,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||||
>
|
>
|
||||||
<div className={cn('flex flex-1 flex-col')}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
@@ -118,15 +183,79 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Signature">Signature</Label>
|
<Label htmlFor="Signature">Signature</Label>
|
||||||
|
|
||||||
<Card className="mt-2" gradient degrees={-120}>
|
<Card id="signature" className="mt-4" degrees={-120} gradient>
|
||||||
<CardContent className="p-0">
|
<CardContent role="button" className="relative cursor-pointer pt-6">
|
||||||
<SignaturePad
|
<div className="flex h-44 max-w-[18rem] items-center justify-center pb-6">
|
||||||
className="h-44 w-full"
|
{!signatureText && (
|
||||||
defaultValue={signature ?? undefined}
|
<SignaturePad
|
||||||
onChange={(value) => {
|
className="h-44"
|
||||||
setSignature(value);
|
defaultValue={signature ?? undefined}
|
||||||
}}
|
clearSignatureClassName="absolute -bottom-6 -right-2 z-10 cursor-pointer"
|
||||||
/>
|
undoSignatureClassName="absolute -top-32 -left-4 z-10 cursor-pointer"
|
||||||
|
onChange={(value) => {
|
||||||
|
setSignature(value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{signatureText && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
|
||||||
|
fontFamily: `var(${fontVariable})`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'text-foreground font-signature max-w-[18rem] text-4xl font-semibold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{signatureText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 flex cursor-auto items-end justify-between px-4 pb-1 pt-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="signatureText"
|
||||||
|
className="text-foreground placeholder:text-muted-foreground max-w-[15rem] border-0 border-none bg-transparent p-0 text-sm focus-visible:ring-transparent"
|
||||||
|
placeholder="Draw or type your name here"
|
||||||
|
disabled={isSubmitting || signature?.startsWith('data:')}
|
||||||
|
{...register('signatureText', {
|
||||||
|
onChange: (e) => {
|
||||||
|
if (e.target.value !== '') {
|
||||||
|
setValue('signatureDataUrl', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue('signatureText', e.target.value);
|
||||||
|
},
|
||||||
|
|
||||||
|
onBlur: (e) => {
|
||||||
|
if (e.target.value === '') {
|
||||||
|
return setValue('signatureText', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSignature(e.target.value.trimStart());
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
{signatureText && (
|
||||||
|
<div className="absolute bottom-3 right-4 z-10 cursor-pointer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
|
||||||
|
onClick={() => {
|
||||||
|
setValue('signatureText', '');
|
||||||
|
setValue('signatureDataUrl', null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Signature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,8 +276,9 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
<SignDialog
|
<SignDialog
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
document={document}
|
document={_document}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { GetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
|
|
||||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
@@ -13,16 +12,10 @@ export type SigningLayoutProps = {
|
|||||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||||
const { user, session } = await getServerComponentSession();
|
const { user, session } = await getServerComponentSession();
|
||||||
|
|
||||||
let teams: GetTeamsResponse = [];
|
|
||||||
|
|
||||||
if (user && session) {
|
|
||||||
teams = await getTeams({ userId: user.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{user && <AuthenticatedHeader user={user} teams={teams} />}
|
{user && <AuthenticatedHeader user={user} />}
|
||||||
|
|
||||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
@@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { notFound, redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
@@ -14,6 +17,8 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
import { DateField } from './date-field';
|
||||||
import { EmailField } from './email-field';
|
import { EmailField } from './email-field';
|
||||||
import { SigningForm } from './form';
|
import { SigningForm } from './form';
|
||||||
@@ -42,10 +47,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
viewedDocument({ token }).catch(() => null),
|
viewedDocument({ token }).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
|
||||||
|
|
||||||
if (!document || !document.documentData || !recipient) {
|
if (!document || !document.documentData || !recipient) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
const { documentData } = document;
|
const { documentData } = document;
|
||||||
|
|
||||||
const { user } = await getServerComponentSession();
|
const { user } = await getServerComponentSession();
|
||||||
@@ -77,7 +86,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
<div className="mx-auto w-full max-w-screen-xl">
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
{document.title}
|
{truncatedTitle}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
@@ -111,7 +120,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
<NameField key={field.id} field={field} recipient={recipient} />
|
<NameField key={field.id} field={field} recipient={recipient} />
|
||||||
))
|
))
|
||||||
.with(FieldType.DATE, () => (
|
.with(FieldType.DATE, () => (
|
||||||
<DateField key={field.id} field={field} recipient={recipient} />
|
<DateField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
recipient={recipient}
|
||||||
|
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||||
|
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.EMAIL, () => (
|
.with(FieldType.EMAIL, () => (
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Document, Field } from '@documenso/prisma/client';
|
import type { Document, Field } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -9,10 +9,13 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
export type SignDialogProps = {
|
export type SignDialogProps = {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
document: Document;
|
document: Document;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: () => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,30 +23,31 @@ export const SignDialog = ({
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
document,
|
document,
|
||||||
fields,
|
fields,
|
||||||
|
fieldsValidated,
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
}: SignDialogProps) => {
|
}: SignDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
const isComplete = fields.every((field) => field.inserted);
|
const isComplete = fields.every((field) => field.inserted);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!isComplete}
|
onClick={fieldsValidated}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
Complete
|
{isComplete ? 'Complete' : 'Next field'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
|
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
|
||||||
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||||
You are about to finish signing "{document.title}". Are you sure?
|
You are about to finish signing "{truncatedTitle}". Are you sure?
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value,
|
value,
|
||||||
isBase64: true,
|
isBase64: typeof value === 'string' && value.startsWith('data:image/png;base64,'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source === 'local' && !providedSignature) {
|
if (source === 'local' && !providedSignature) {
|
||||||
@@ -127,7 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||||
|
|
||||||
export type SignatureFieldProps = {
|
export type SignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
@@ -11,6 +12,8 @@ export type SignatureFieldProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSign?: () => Promise<void> | void;
|
onSign?: () => Promise<void> | void;
|
||||||
onRemove?: () => Promise<void> | void;
|
onRemove?: () => Promise<void> | void;
|
||||||
|
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
||||||
|
tooltipText?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SigningFieldContainer = ({
|
export const SigningFieldContainer = ({
|
||||||
@@ -19,6 +22,8 @@ export const SigningFieldContainer = ({
|
|||||||
onSign,
|
onSign,
|
||||||
onRemove,
|
onRemove,
|
||||||
children,
|
children,
|
||||||
|
type,
|
||||||
|
tooltipText,
|
||||||
}: SignatureFieldProps) => {
|
}: SignatureFieldProps) => {
|
||||||
const onSignFieldClick = async () => {
|
const onSignFieldClick = async () => {
|
||||||
if (field.inserted) {
|
if (field.inserted) {
|
||||||
@@ -46,7 +51,22 @@ export const SigningFieldContainer = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{field.inserted && !loading && (
|
{type === 'Date' && field.inserted && !loading && (
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||||
|
onClick={onRemoveSignedFieldClick}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
|
||||||
|
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type !== 'Date' && field.inserted && !loading && (
|
||||||
<button
|
<button
|
||||||
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
|
||||||
onClick={onRemoveSignedFieldClick}
|
onClick={onRemoveSignedFieldClick}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { getServerSession } from 'next-auth';
|
|
||||||
|
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
|
||||||
|
|
||||||
export type AuthenticatedDashboardLayoutProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function AuthenticatedTeamsDashboardLayout({
|
|
||||||
children,
|
|
||||||
}: AuthenticatedDashboardLayoutProps) {
|
|
||||||
const session = await getServerSession(NEXT_AUTH_OPTIONS);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
redirect('/signin');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [{ user }, teams] = await Promise.all([
|
|
||||||
getRequiredServerComponentSession(),
|
|
||||||
getTeams({ userId: session.user.id }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NextAuthProvider session={session}>
|
|
||||||
<LimitsProvider>
|
|
||||||
<Header user={user} teams={teams} />
|
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
|
||||||
|
|
||||||
<RefreshOnFocus />
|
|
||||||
</LimitsProvider>
|
|
||||||
</NextAuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
|
|
||||||
import DocumentPageComponent from '~/app/(dashboard)/documents/[id]/document-page-component';
|
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
|
||||||
const { teamUrl } = params;
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
|
||||||
|
|
||||||
return <DocumentPageComponent params={params} team={team} />;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function DocumentSentPage() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
|
||||||
<Link href="/documents" className="flex grow-0 items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
|
||||||
Loading Document...
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
|
|
||||||
import type { DocumentsPageComponentProps } from '~/app/(dashboard)/documents/documents-page-component';
|
|
||||||
import DocumentsPageComponent from '~/app/(dashboard)/documents/documents-page-component';
|
|
||||||
|
|
||||||
export type TeamsDocumentPageProps = {
|
|
||||||
params: {
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
searchParams?: DocumentsPageComponentProps['searchParams'];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TeamsDocumentPage({
|
|
||||||
params,
|
|
||||||
searchParams = {},
|
|
||||||
}: TeamsDocumentPageProps) {
|
|
||||||
const { teamUrl } = params;
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
|
||||||
|
|
||||||
return <DocumentsPageComponent searchParams={searchParams} team={team} />;
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
type ErrorProps = {
|
|
||||||
error: Error & { digest?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ErrorPage({ error }: ErrorProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
let errorMessage = 'Unknown error';
|
|
||||||
let errorDetails = '';
|
|
||||||
|
|
||||||
if (error.message === AppErrorCode.UNAUTHORIZED) {
|
|
||||||
errorMessage = 'Unauthorized';
|
|
||||||
errorDetails = 'You are not authorized to view this page.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground font-semibold">{errorMessage}</p>
|
|
||||||
|
|
||||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 text-sm">{errorDetails}</p>
|
|
||||||
|
|
||||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-32"
|
|
||||||
onClick={() => {
|
|
||||||
void router.back();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
||||||
Go Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/settings/teams">View teams</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground font-semibold">404 Team not found</p>
|
|
||||||
|
|
||||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 text-sm">
|
|
||||||
The team you are looking for may have been removed, renamed or may have never existed.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
|
||||||
<Button asChild className="w-32">
|
|
||||||
<Link href="/settings/teams">
|
|
||||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
||||||
Go Back
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { DateTime } from 'luxon';
|
|
||||||
import type Stripe from 'stripe';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|
||||||
|
|
||||||
import { BillingPortalButton } from '~/app/(dashboard)/settings/billing/billing-portal-button';
|
|
||||||
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import TeamBillingInvoicesDataTable from '~/components/(teams)/tables/team-billing-invoices-data-table';
|
|
||||||
|
|
||||||
export type TeamsSettingsBillingPageProps = {
|
|
||||||
params: {
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
|
|
||||||
const { teamUrl } = params;
|
|
||||||
|
|
||||||
const session = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
|
||||||
|
|
||||||
const isUserOwnerOfTeam = team.ownerUserId === session.user.id;
|
|
||||||
|
|
||||||
let teamSubscription: Stripe.Subscription | null = null;
|
|
||||||
|
|
||||||
if (team.subscriptionId) {
|
|
||||||
teamSubscription = await stripe.subscriptions.retrieve(team.subscriptionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
|
|
||||||
if (!subscription) {
|
|
||||||
return 'No payment required';
|
|
||||||
}
|
|
||||||
|
|
||||||
const numberOfSeats = subscription.items.data[0].quantity ?? 0;
|
|
||||||
|
|
||||||
const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member';
|
|
||||||
|
|
||||||
const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
|
|
||||||
'LLL dd, yyyy',
|
|
||||||
);
|
|
||||||
|
|
||||||
return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader title="Billing" subtitle="Your subscription is currently active." />
|
|
||||||
|
|
||||||
<Card gradient className="shadow-sm">
|
|
||||||
<CardContent className="flex flex-row items-center justify-between p-4">
|
|
||||||
<div className="flex flex-col text-sm">
|
|
||||||
<p className="text-foreground font-semibold">
|
|
||||||
Current plan: {teamSubscription ? 'Team' : 'Community Team'}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-0.5">
|
|
||||||
{formatTeamSubscriptionDetails(teamSubscription)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{teamSubscription && (
|
|
||||||
<div
|
|
||||||
title={
|
|
||||||
isUserOwnerOfTeam
|
|
||||||
? 'Manage your team subscription.'
|
|
||||||
: 'You must be the owner of this team to directly manage the billing.'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<BillingPortalButton buttonProps={{ disabled: !isUserOwnerOfTeam }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<section className="mt-6">
|
|
||||||
<TeamBillingInvoicesDataTable teamId={team.id} />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
import { canExecuteTeamAction } from '@documenso/lib/constants/teams';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
|
|
||||||
import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
|
|
||||||
import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
|
|
||||||
|
|
||||||
export type DashboardSettingsLayoutProps = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
params: {
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TeamsSettingsLayout({
|
|
||||||
children,
|
|
||||||
params: { teamUrl },
|
|
||||||
}: DashboardSettingsLayoutProps) {
|
|
||||||
const session = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
|
||||||
|
|
||||||
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
|
|
||||||
throw new Error(AppErrorCode.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const error = AppError.parseError(e);
|
|
||||||
|
|
||||||
if (error.code === 'P2025') {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<h1 className="text-4xl font-semibold">Team Settings</h1>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
|
||||||
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
|
||||||
<MobileNav className="col-span-12 mb-8 md:hidden" />
|
|
||||||
|
|
||||||
<div className="col-span-12 md:col-span-9">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
|
|
||||||
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import InviteTeamMembersDialog from '~/components/(teams)/dialogs/invite-team-member-dialog';
|
|
||||||
import TeamsMemberPageDataTable from '~/components/(teams)/tables/teams-member-page-data-table';
|
|
||||||
|
|
||||||
export type TeamsSettingsMembersPageProps = {
|
|
||||||
params: {
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
|
|
||||||
const { teamUrl } = params;
|
|
||||||
|
|
||||||
const session = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader title="Members" subtitle="Manage the members or invite new members.">
|
|
||||||
<InviteTeamMembersDialog teamId={team.id} />
|
|
||||||
</SettingsHeader>
|
|
||||||
|
|
||||||
<TeamsMemberPageDataTable
|
|
||||||
teamId={team.id}
|
|
||||||
teamName={team.name}
|
|
||||||
teamOwnerUserId={team.ownerUserId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { CheckCircle2, Clock } from 'lucide-react';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|
||||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
|
|
||||||
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
|
|
||||||
import AddTeamEmailDialog from '~/components/(teams)/dialogs/add-team-email-dialog';
|
|
||||||
import DeleteTeamDialog from '~/components/(teams)/dialogs/delete-team-dialog';
|
|
||||||
import TransferTeamDialog from '~/components/(teams)/dialogs/transfer-team-dialog';
|
|
||||||
import UpdateTeamForm from '~/components/(teams)/forms/update-team-form';
|
|
||||||
|
|
||||||
import TeamEmailDropdown from './team-email-dropdown';
|
|
||||||
import { TeamTransferStatus } from './team-transfer-status';
|
|
||||||
|
|
||||||
export type TeamsSettingsPageProps = {
|
|
||||||
params: {
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
|
|
||||||
const { teamUrl } = params;
|
|
||||||
|
|
||||||
const session = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
|
|
||||||
|
|
||||||
const isTransferVerificationExpired =
|
|
||||||
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<SettingsHeader title="Team Profile" subtitle="Here you can edit your team's details." />
|
|
||||||
|
|
||||||
<TeamTransferStatus
|
|
||||||
teamId={team.id}
|
|
||||||
transferVerification={team.transferVerification}
|
|
||||||
className="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
|
||||||
|
|
||||||
<section className="mt-6 space-y-6">
|
|
||||||
{(team.teamEmail || team.emailVerification) && (
|
|
||||||
<section className="rounded-lg bg-gray-50/70 p-6 pb-2">
|
|
||||||
<h3 className="font-medium">Team email</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
You can view documents associated with this email and use this identity when sending
|
|
||||||
documents.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="border-border/50 mt-2" />
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center justify-between py-4">
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={extractInitials(
|
|
||||||
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
|
|
||||||
)}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 text-sm font-semibold">
|
|
||||||
{team.teamEmail?.name || team.emailVerification?.name}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
secondaryText={
|
|
||||||
<span className="text-sm">
|
|
||||||
{team.teamEmail?.email || team.emailVerification?.email}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center pr-2">
|
|
||||||
<div className="text-muted-foreground mr-4 flex flex-row items-center text-sm xl:mr-8">
|
|
||||||
{team.teamEmail ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
|
|
||||||
Active
|
|
||||||
</>
|
|
||||||
) : team.emailVerification && team.emailVerification.expiresAt < new Date() ? (
|
|
||||||
<>
|
|
||||||
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
|
|
||||||
Expired
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
team.emailVerification && (
|
|
||||||
<>
|
|
||||||
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
|
|
||||||
Awaiting email confirmation
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TeamEmailDropdown team={team} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!team.teamEmail && !team.emailVerification && (
|
|
||||||
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Team email</h3>
|
|
||||||
|
|
||||||
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
|
|
||||||
<li>Display this name and email when sending documents</li>
|
|
||||||
<li>View documents associated with this email</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AddTeamEmailDialog teamId={team.id} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{team.ownerUserId === session.user.id && (
|
|
||||||
<>
|
|
||||||
{isTransferVerificationExpired && (
|
|
||||||
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Transfer team</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Transfer the ownership of the team to another team member.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TransferTeamDialog
|
|
||||||
ownerUserId={team.ownerUserId}
|
|
||||||
teamId={team.id}
|
|
||||||
teamName={team.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium">Delete team</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
This team, and any associated data excluding billing invoices will be permanently
|
|
||||||
deleted.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DeleteTeamDialog teamId={team.id} teamName={team.name} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
|
|
||||||
|
|
||||||
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import UpdateTeamEmailDialog from '~/components/(teams)/dialogs/update-team-email-dialog';
|
|
||||||
|
|
||||||
export type TeamsSettingsPageProps = {
|
|
||||||
team: Awaited<ReturnType<typeof getTeamByUrl>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TeamEmailDropdown({ team }: TeamsSettingsPageProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } =
|
|
||||||
trpc.team.resendTeamEmailVerification.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Email verification has been resent',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description: 'Unable to resend verification at this time. Please try again.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
|
|
||||||
trpc.team.deleteTeamEmail.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Team email has been removed',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description: 'Unable to remove team email at this time. Please try again.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
|
|
||||||
trpc.team.deleteTeamEmailVerification.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Email verification has been removed',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description: 'Unable to remove email verification at this time. Please try again.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onRemove = async () => {
|
|
||||||
if (team.teamEmail) {
|
|
||||||
await deleteTeamEmail({ teamId: team.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (team.emailVerification) {
|
|
||||||
await deleteTeamEmailVerification({ teamId: team.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
|
||||||
{!team.teamEmail && team.emailVerification && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={isResendingEmailVerification}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
void resendEmailVerification({ teamId: team.id });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isResendingEmailVerification ? (
|
|
||||||
<Loader className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Resend verification
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{team.teamEmail && (
|
|
||||||
<UpdateTeamEmailDialog
|
|
||||||
teamEmail={team.teamEmail}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={isDeletingTeamEmail || isDeletingTeamEmailVerification}
|
|
||||||
onClick={async () => onRemove()}
|
|
||||||
>
|
|
||||||
<X className="mr-2 h-4 w-4" />
|
|
||||||
Remove
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
|
|
||||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
|
||||||
import type { TeamTransferVerification } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type TeamTransferStatusProps = {
|
|
||||||
className?: string;
|
|
||||||
teamId: number;
|
|
||||||
transferVerification: TeamTransferVerification | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TeamTransferStatus = ({
|
|
||||||
className,
|
|
||||||
teamId,
|
|
||||||
transferVerification,
|
|
||||||
}: TeamTransferStatusProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamTransferRequest, isLoading } =
|
|
||||||
trpc.team.deleteTeamTransferRequest.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
if (!isExpired) {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'The team transfer invitation has been successfully deleted.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{transferVerification && (
|
|
||||||
<motion.div
|
|
||||||
className={cn(
|
|
||||||
'flex flex-row items-center justify-between rounded-lg border-2 border-yellow-400 bg-yellow-200 px-6 py-4 dark:border-yellow-600 dark:bg-yellow-400',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-yellow-900">
|
|
||||||
<h3 className="font-medium">
|
|
||||||
{isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{isExpired ? (
|
|
||||||
<p className="text-sm">
|
|
||||||
The team transfer request to <strong>{transferVerification.name}</strong> has
|
|
||||||
expired.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<section className="text-sm">
|
|
||||||
<p>
|
|
||||||
A request to transfer the ownership of this team has been sent to{' '}
|
|
||||||
<strong>{transferVerification.name}</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>If they accept this request, the team will be transferred to their account.</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={async () => deleteTeamTransferRequest({ teamId })}
|
|
||||||
loading={isLoading}
|
|
||||||
variant="destructive"
|
|
||||||
className="ml-auto mt-2"
|
|
||||||
>
|
|
||||||
{isExpired ? 'Close' : 'Cancel'}
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -2,15 +2,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
|
|
||||||
type SignInPageProps = {
|
export default function SignInPage() {
|
||||||
searchParams: {
|
|
||||||
email?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SignInPage({ searchParams }: SignInPageProps) {
|
|
||||||
const email = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||||
@@ -19,7 +11,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
|
|||||||
Welcome back, we are lucky to have you.
|
Welcome back, we are lucky to have you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignInForm initialEmail={email} className="mt-4" />
|
<SignInForm className="mt-4" />
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
|||||||
@@ -3,19 +3,11 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
type SignUpPageProps = {
|
export default function SignUpPage() {
|
||||||
searchParams: {
|
|
||||||
email?: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||||
@@ -25,7 +17,7 @@ export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
|||||||
signing is within your grasp.
|
signing is within your grasp.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignUpForm initialEmail={email} className="mt-4" />
|
<SignUpForm className="mt-4" />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
|
|
||||||
import { getTeamById } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
type AcceptInvitationPageProps = {
|
|
||||||
params: {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function AcceptInvitationPage({
|
|
||||||
params: { token },
|
|
||||||
}: AcceptInvitationPageProps) {
|
|
||||||
const session = await getServerComponentSession();
|
|
||||||
|
|
||||||
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
|
|
||||||
where: {
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!teamMemberInvite) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
|
||||||
This token is invalid or has expired. Please contact your team for a new invitation.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/">Return</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = await getTeamById({ teamId: teamMemberInvite.teamId });
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
email: {
|
|
||||||
equals: teamMemberInvite.email,
|
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Directly convert the team member invite to a team member if they already have an account.
|
|
||||||
if (user) {
|
|
||||||
await acceptTeamInvitation({ userId: user.id, teamId: team.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the team invite status to accepted, which is checked during user creation
|
|
||||||
// to determine if we should add the user to the team at that time.
|
|
||||||
if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) {
|
|
||||||
await prisma.teamMemberInvite.update({
|
|
||||||
where: {
|
|
||||||
id: teamMemberInvite.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: TeamMemberInviteStatus.ACCEPTED,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSessionUserTheInvitedUser = user && user.id === session.user?.id;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Team invitation</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
You have been invited by <strong>{team.name}</strong> to join their team.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-1 text-sm">
|
|
||||||
To accept this invitation you must create an account.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/signup?email=${encodeURIComponent(teamMemberInvite.email)}`}>
|
|
||||||
Create account
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Invitation accepted!</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
|
||||||
You have accepted an invitation from <strong>{team.name}</strong> to join their team.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{user && !isSessionUserTheInvitedUser && (
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/signin?email=${encodeURIComponent(teamMemberInvite.email)}`}>
|
|
||||||
Continue to login
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isSessionUserTheInvitedUser && (
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/">Continue</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
type VerifyTeamEmailPageProps = {
|
|
||||||
params: {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) {
|
|
||||||
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
|
|
||||||
where: {
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!teamEmailVerification || teamEmailVerification.expiresAt < new Date()) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
|
||||||
This link is invalid or has expired. Please contact your team to resend a verification.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/">Return</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { team } = teamEmailVerification;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.teamEmailVerification.deleteMany({
|
|
||||||
where: {
|
|
||||||
teamId: team.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.teamEmail.create({
|
|
||||||
data: {
|
|
||||||
teamId: team.id,
|
|
||||||
email: teamEmailVerification.email,
|
|
||||||
name: teamEmailVerification.name,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Team email verification</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
Something went wrong while attempting to verify your email address for{' '}
|
|
||||||
<strong>{team.name}</strong>. Please try again later.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Team email verified!</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
|
||||||
You have verified your email address for <strong>{team.name}</strong>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/">Continue</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
type VerifyTeamTransferPage = {
|
|
||||||
params: {
|
|
||||||
token: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function VerifyTeamTransferPage({
|
|
||||||
params: { token },
|
|
||||||
}: VerifyTeamTransferPage) {
|
|
||||||
const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
|
|
||||||
where: {
|
|
||||||
token,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
team: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!teamTransferVerification || teamTransferVerification.expiresAt < new Date()) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
|
||||||
This link is invalid or has expired. Please contact your team to resend a transfer
|
|
||||||
request.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/">Return</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { team } = teamTransferVerification;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await transferTeamOwnership({ token });
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Team ownership transfer</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
Something went wrong while attempting to transfer the ownership of team{' '}
|
|
||||||
<strong>{team.name}</strong> to your. Please try again later or contact support.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-semibold">Team ownership transferred!</h1>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
|
||||||
The ownership of team <strong>{team.name}</strong> has been successfully transferred to you.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button asChild>
|
|
||||||
<Link href={`/t/${team.url}/settings`}>Continue</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||||||
import {
|
import {
|
||||||
DOCUMENTS_PAGE_SHORTCUT,
|
DOCUMENTS_PAGE_SHORTCUT,
|
||||||
SETTINGS_PAGE_SHORTCUT,
|
SETTINGS_PAGE_SHORTCUT,
|
||||||
|
TEMPLATES_PAGE_SHORTCUT,
|
||||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +40,14 @@ const DOCUMENTS_PAGES = [
|
|||||||
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TEMPLATES_PAGES = [
|
||||||
|
{
|
||||||
|
label: 'All templates',
|
||||||
|
path: '/templates',
|
||||||
|
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const SETTINGS_PAGES = [
|
const SETTINGS_PAGES = [
|
||||||
{
|
{
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
@@ -86,8 +95,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
|
|
||||||
const currentPage = pages[pages.length - 1];
|
const currentPage = pages[pages.length - 1];
|
||||||
|
|
||||||
const toggleOpen = (e: KeyboardEvent) => {
|
const toggleOpen = () => {
|
||||||
e.preventDefault();
|
|
||||||
setIsOpen((isOpen) => !isOpen);
|
setIsOpen((isOpen) => !isOpen);
|
||||||
onOpenChange?.(!isOpen);
|
onOpenChange?.(!isOpen);
|
||||||
|
|
||||||
@@ -125,10 +133,12 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
|
|
||||||
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
||||||
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
||||||
|
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
|
||||||
|
|
||||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen);
|
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
|
||||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||||
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||||
|
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
// Escape goes to previous page
|
// Escape goes to previous page
|
||||||
@@ -175,6 +185,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
<CommandGroup heading="Documents">
|
<CommandGroup heading="Documents">
|
||||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Templates">
|
||||||
|
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||||
|
</CommandGroup>
|
||||||
<CommandGroup heading="Settings">
|
<CommandGroup heading="Settings">
|
||||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
@@ -16,12 +15,9 @@ import { ProfileDropdown } from './profile-dropdown';
|
|||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
user: User;
|
user: User;
|
||||||
teams: GetTeamsResponse;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,18 +30,10 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
return () => window.removeEventListener('scroll', onScroll);
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getRootHref = () => {
|
|
||||||
if (typeof params?.teamUrl === 'string') {
|
|
||||||
return `/t/${params.teamUrl}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '/';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[1000] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[50] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||||
scrollY > 5 && 'border-b-border',
|
scrollY > 5 && 'border-b-border',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -53,7 +41,7 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
>
|
>
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||||
<Link
|
<Link
|
||||||
href={getRootHref()}
|
href="/"
|
||||||
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Logo className="h-6 w-auto" />
|
<Logo className="h-6 w-auto" />
|
||||||
@@ -62,7 +50,11 @@ export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
|||||||
<DesktopNav />
|
<DesktopNav />
|
||||||
|
|
||||||
<div className="flex gap-x-4 md:ml-8">
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
<ProfileDropdown user={user} teams={teams} />
|
<ProfileDropdown user={user} />
|
||||||
|
|
||||||
|
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,210 +1,162 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
import {
|
||||||
|
CreditCard,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Lock,
|
||||||
|
LogOut,
|
||||||
|
User as LucideUser,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Palette,
|
||||||
|
Sun,
|
||||||
|
UserCog,
|
||||||
|
} from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP, canExecuteTeamAction } from '@documenso/lib/constants/teams';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
export type ProfileDropdownProps = {
|
export type ProfileDropdownProps = {
|
||||||
user: User;
|
user: User;
|
||||||
teams: GetTeamsResponse;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProfileDropdown = ({ user, teams: initialTeamsData }: ProfileDropdownProps) => {
|
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||||
const pathname = usePathname();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
const isUserAdmin = isAdmin(user);
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
initialData: initialTeamsData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
|
const avatarFallback = user.name
|
||||||
|
? recipientInitials(user.name)
|
||||||
const isPathTeamUrl = (teamUrl: string) => {
|
: user.email.slice(0, 1).toUpperCase();
|
||||||
if (!pathname || !pathname.startsWith(`/t/`)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathname.split('/')[2] === teamUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedTeam = teams?.find((team) => isPathTeamUrl(team.url));
|
|
||||||
|
|
||||||
const formatAvatarFallback = (teamName?: string) => {
|
|
||||||
if (teamName !== undefined) {
|
|
||||||
return teamName.slice(0, 1).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
return user.name ? extractInitials(user.name) : user.email.slice(0, 1).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSecondaryAvatarText = (team?: typeof selectedTeam) => {
|
|
||||||
if (!team) {
|
|
||||||
return 'Personal Account';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (team.ownerUserId === user.id) {
|
|
||||||
return 'Owner';
|
|
||||||
}
|
|
||||||
|
|
||||||
return TEAM_MEMBER_ROLE_MAP[team.currentTeamMember.role];
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="relative flex h-12 flex-row items-center px-2 py-2 focus-visible:ring-0"
|
title="Profile Dropdown"
|
||||||
|
className="relative h-10 w-10 rounded-full"
|
||||||
>
|
>
|
||||||
{/* Todo */}
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarWithText
|
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
</Avatar>
|
||||||
primaryText={selectedTeam ? selectedTeam.name : user.name}
|
|
||||||
secondaryText={formatSecondaryAvatarText(selectedTeam)}
|
|
||||||
rightSideComponent={
|
|
||||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
className={cn('w-full', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
align="end"
|
|
||||||
forceMount
|
|
||||||
>
|
|
||||||
{teams ? (
|
|
||||||
<>
|
|
||||||
<DropdownMenuLabel>Personal</DropdownMenuLabel>
|
|
||||||
|
|
||||||
|
{isUserAdmin && (
|
||||||
|
<>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/">
|
<Link href="/admin" className="cursor-pointer">
|
||||||
<AvatarWithText
|
<UserCog className="mr-2 h-4 w-4" />
|
||||||
avatarFallback={formatAvatarFallback()}
|
Admin
|
||||||
primaryText={user.name}
|
|
||||||
secondaryText={formatSecondaryAvatarText()}
|
|
||||||
rightSideComponent={
|
|
||||||
!pathname?.startsWith(`/t/`) && (
|
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator className="mt-2" />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
<div className="flex flex-row items-center justify-between">
|
|
||||||
<p>Teams</p>
|
|
||||||
|
|
||||||
<div className="flex flex-row space-x-2">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Button
|
|
||||||
title="Manage teams"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link href="/settings/teams">
|
|
||||||
<Settings2 className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Button
|
|
||||||
title="Manage teams"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-muted-foreground flex h-5 w-5 items-center justify-center p-0"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link href="/settings/teams?action=add-team">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
{teams.map((team) => (
|
|
||||||
<DropdownMenuItem asChild key={team.id}>
|
|
||||||
<Link href={`/t/${team.url}`}>
|
|
||||||
<AvatarWithText
|
|
||||||
avatarFallback={formatAvatarFallback(team.name)}
|
|
||||||
primaryText={team.name}
|
|
||||||
secondaryText={formatSecondaryAvatarText(team)}
|
|
||||||
rightSideComponent={
|
|
||||||
isPathTeamUrl(team.url) && (
|
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
)}
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
|
||||||
<Link
|
<DropdownMenuItem asChild>
|
||||||
href="/settings/teams?action=add-team"
|
<Link href="/settings/profile" className="cursor-pointer">
|
||||||
className="flex items-center justify-between"
|
<LucideUser className="mr-2 h-4 w-4" />
|
||||||
>
|
Profile
|
||||||
Create team
|
</Link>
|
||||||
<Plus className="ml-2 h-4 w-4" />
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings/security" className="cursor-pointer">
|
||||||
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
|
Security
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{isBillingEnabled && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/settings/billing" className="cursor-pointer">
|
||||||
|
<CreditCard className="mr-2 h-4 w-4" />
|
||||||
|
Billing
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/templates" className="cursor-pointer">
|
||||||
|
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||||
|
Templates
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
{isUserAdmin && (
|
<DropdownMenuSub>
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
<DropdownMenuSubTrigger>
|
||||||
<Link href="/admin">Admin panel</Link>
|
<Palette className="mr-2 h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
Themes
|
||||||
)}
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
<DropdownMenuSubContent>
|
||||||
<Link href="/settings/profile">User settings</Link>
|
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
||||||
|
<DropdownMenuRadioItem value="light">
|
||||||
|
<Sun className="mr-2 h-4 w-4" /> Light
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="dark">
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
Dark
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
<DropdownMenuRadioItem value="system">
|
||||||
|
<Monitor className="mr-2 h-4 w-4" />
|
||||||
|
System
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
||||||
|
<LuGithub className="mr-2 h-4 w-4" />
|
||||||
|
Star on Github
|
||||||
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{selectedTeam &&
|
<DropdownMenuSeparator />
|
||||||
canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && (
|
|
||||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
|
||||||
<Link href={`/t/${selectedTeam.url}/settings/`}>Team settings</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive/90 hover:!text-destructive px-4 py-2"
|
onSelect={() =>
|
||||||
onSelect={async () =>
|
void signOut({
|
||||||
signOut({
|
|
||||||
callbackUrl: '/',
|
callbackUrl: '/',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
Sign Out
|
Sign Out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
import { CreditCard, Lock, User } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -35,19 +35,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/teams">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Users className="mr-2 h-5 w-5" />
|
|
||||||
Teams
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export type SettingsHeaderProps = {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SettingsHeader({ children, title, subtitle }: SettingsHeaderProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-row items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">{title}</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">{subtitle}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
import { CreditCard, Lock, User } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -38,19 +38,6 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/teams">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Users className="mr-2 h-5 w-5" />
|
|
||||||
Teams
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZAddTeamEmailVerificationMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
|
||||||
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 AddTeamEmailDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const ZAddTeamEmailFormSchema = ZAddTeamEmailVerificationMutationSchema.pick({
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TAddTeamEmailFormSchema = z.infer<typeof ZAddTeamEmailFormSchema>;
|
|
||||||
|
|
||||||
export default function AddTeamEmailDialog({ teamId, trigger, ...props }: AddTeamEmailDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<TAddTeamEmailFormSchema>({
|
|
||||||
resolver: zodResolver(ZAddTeamEmailFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: addTeamEmailVerification, isLoading } =
|
|
||||||
trpc.team.addTeamEmailVerification.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, email }: TAddTeamEmailFormSchema) => {
|
|
||||||
try {
|
|
||||||
await addTeamEmailVerification({
|
|
||||||
teamId,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'We have sent a confirmation email for verification.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
form.setError('email', {
|
|
||||||
type: 'manual',
|
|
||||||
message: 'This email is already being used by another team.',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to add this email. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
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 variant="outline" loading={isLoading} className="bg-background">
|
|
||||||
<Plus className="-ml-1 mr-1 h-5 w-5" />
|
|
||||||
Add email
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add team email</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
A verification email will be sent to the provided email.
|
|
||||||
</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>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" placeholder="eg. Legal" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>Email</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="bg-background"
|
|
||||||
placeholder="example@example.com"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { CreditCard } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
|
||||||
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 CreateTeamDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
|
|
||||||
name: true,
|
|
||||||
url: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
|
||||||
|
|
||||||
export default function CreateTeamDialog({ trigger, ...props }: CreateTeamDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [checkoutUrl, setCheckoutUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const actionSearchParam = searchParams?.get('action');
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZCreateTeamFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, url }: TCreateTeamFormSchema) => {
|
|
||||||
try {
|
|
||||||
const response = await createTeam({
|
|
||||||
name,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.paymentRequired) {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Your team has been successfully created.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCheckoutUrl(response.checkoutUrl);
|
|
||||||
router.push(`/settings/teams?tab=pending`);
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: 'This URL is already in use.',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to create a team. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapTextToUrl = (text: string) => {
|
|
||||||
return text.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (actionSearchParam === 'add-team') {
|
|
||||||
setOpen(true);
|
|
||||||
updateSearchParams({ action: null });
|
|
||||||
}
|
|
||||||
}, [actionSearchParam, open, setOpen, updateSearchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCheckoutUrl(null);
|
|
||||||
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 variant="secondary">Create team</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create team</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
Create a team to collaborate with your team members.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{checkoutUrl ? (
|
|
||||||
<>
|
|
||||||
<div className="flex h-44 flex-col items-center justify-center rounded-lg bg-gray-50/70">
|
|
||||||
<CreditCard className="h-8 w-8 text-gray-600" />
|
|
||||||
<span className="text-muted-foreground text-sm font-medium">
|
|
||||||
Payment is required to finalise the creation of your team.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" asChild>
|
|
||||||
<Link href={checkoutUrl} onClick={() => setOpen(false)} target="_blank">
|
|
||||||
Checkout
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<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>Team Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="bg-background"
|
|
||||||
{...field}
|
|
||||||
onChange={(event) => {
|
|
||||||
const oldGenericUrl = mapTextToUrl(field.value);
|
|
||||||
const newGenericUrl = mapTextToUrl(event.target.value);
|
|
||||||
|
|
||||||
const urlField = form.getValues('url');
|
|
||||||
if (urlField === oldGenericUrl) {
|
|
||||||
form.setValue('url', newGenericUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
field.onChange(event);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>Team URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
{!form.formState.errors.url && (
|
|
||||||
<span className="text-foreground/50 text-xs font-normal">
|
|
||||||
{field.value
|
|
||||||
? `${WEBAPP_BASE_URL}/t/${field.value}`
|
|
||||||
: 'A unique URL to identify your team'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
Create Team
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
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 type { Toast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type DeleteTeamDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeleteTeamDialog({ trigger, teamId, teamName }: DeleteTeamDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const deleteMessage = `delete ${teamName}`;
|
|
||||||
|
|
||||||
const ZDeleteTeamFormSchema = z.object({
|
|
||||||
teamName: z.literal(deleteMessage, {
|
|
||||||
errorMap: () => ({ message: `You must enter '${deleteMessage}' to proceed` }),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZDeleteTeamFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
teamName: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeam } = trpc.team.deleteTeam.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await deleteTeam({ teamId });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Your team has been successfully deleted.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
router.push('/settings/teams');
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
let toastError: Toast = {
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to delete this team. Please try again later.',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error.code === 'resource_missing') {
|
|
||||||
toastError = {
|
|
||||||
title: 'Unable to delete team',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 15000,
|
|
||||||
description:
|
|
||||||
'Something went wrong while updating the team billing subscription, please contact support.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toast(toastError);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? <Button variant="destructive">Delete team</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete team</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
Are you sure? This is irreversable.
|
|
||||||
</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="teamName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Confirm by typing <span className="text-destructive">{deleteMessage}</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Avatar, AvatarFallback } 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 DeleteTeamMemberDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
teamMemberId: number;
|
|
||||||
teamMemberName: string;
|
|
||||||
teamMemberEmail: string;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeleteTeamMemberDialog({
|
|
||||||
trigger,
|
|
||||||
teamId,
|
|
||||||
teamName,
|
|
||||||
teamMemberId,
|
|
||||||
teamMemberName,
|
|
||||||
teamMemberEmail,
|
|
||||||
}: DeleteTeamMemberDialogProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamMembers, isLoading: isDeletingTeamMember } =
|
|
||||||
trpc.team.deleteTeamMembers.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'You have successfully removed this user from the team.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to remove this user. Please try again later.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamMember && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? <Button variant="secondary">Delete team member</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Are you sure?</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
You are about to remove the following user from{' '}
|
|
||||||
<span className="font-semibold">{teamName}</span>.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="bg-accent/50 rounded-lg px-4 py-2">
|
|
||||||
<div className="flex max-w-xs items-center gap-2">
|
|
||||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
|
||||||
<AvatarFallback className="text-xs text-gray-400">
|
|
||||||
{teamMemberName.slice(0, 1).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="flex flex-col text-sm">
|
|
||||||
<span className="text-foreground/80 font-semibold">{teamMemberName}</span>
|
|
||||||
<span className="text-muted-foreground">{teamMemberEmail}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset disabled={isDeletingTeamMember}>
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDeletingTeamMember}
|
|
||||||
onClick={async () => deleteTeamMembers({ teamId, teamMemberIds: [teamMemberId] })}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { Mail, PlusCircle, Trash } from 'lucide-react';
|
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { ZCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
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 const ZInviteTeamMembersFormSchema = z
|
|
||||||
.object({
|
|
||||||
invitations: ZCreateTeamMemberInvitesMutationSchema.shape.invitations,
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(schema) => {
|
|
||||||
const emails = schema.invitations.map((invitation) => invitation.email.toLowerCase());
|
|
||||||
|
|
||||||
return new Set(emails).size === emails.length;
|
|
||||||
},
|
|
||||||
// Dirty hack to handle errors when .root is populated for an array type
|
|
||||||
{ message: 'Members must have unique emails', path: ['members__root'] },
|
|
||||||
);
|
|
||||||
|
|
||||||
export type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
|
||||||
|
|
||||||
export type InviteTeamMembersDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export default function InviteTeamMembersDialog({
|
|
||||||
teamId,
|
|
||||||
trigger,
|
|
||||||
...props
|
|
||||||
}: InviteTeamMembersDialogProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<TInviteTeamMembersFormSchema>({
|
|
||||||
resolver: zodResolver(ZInviteTeamMembersFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
invitations: [
|
|
||||||
{
|
|
||||||
email: '',
|
|
||||||
role: TeamMemberRole.MEMBER,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
append: appendTeamMemberInvite,
|
|
||||||
fields: teamMemberInvites,
|
|
||||||
remove: removeTeamMemberInvite,
|
|
||||||
} = useFieldArray({
|
|
||||||
control: form.control,
|
|
||||||
name: 'invitations',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: createTeamMemberInvites } = trpc.team.createTeamMemberInvites.useMutation();
|
|
||||||
|
|
||||||
const onAddTeamMemberInvite = () => {
|
|
||||||
appendTeamMemberInvite({
|
|
||||||
email: '',
|
|
||||||
role: TeamMemberRole.MEMBER,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ invitations }: TInviteTeamMembersFormSchema) => {
|
|
||||||
try {
|
|
||||||
await createTeamMemberInvites({
|
|
||||||
teamId,
|
|
||||||
invitations,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Team invitations have been sent.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to invite team members. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? <Button variant="secondary">Invite member</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Invite team members</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
An email containing an invitation will be sent to each member.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
|
||||||
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{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`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`invitations.${index}.role`}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
{index === 0 && <FormLabel required>Role</FormLabel>}
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent position="popper">
|
|
||||||
{Object.values(TeamMemberRole).map((role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'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={teamMemberInvites.length === 1}
|
|
||||||
onClick={() => removeTeamMemberInvite(index)}
|
|
||||||
>
|
|
||||||
<Trash className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => onAddTeamMemberInvite()}
|
|
||||||
>
|
|
||||||
<PlusCircle className="mr-2 h-4 w-4" />
|
|
||||||
Add more members
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
|
||||||
Invite
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import type { TeamMemberRole } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Avatar, AvatarFallback } 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 LeaveTeamDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
role: TeamMemberRole;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function LeaveTeamDialog({ trigger, teamId, teamName, role }: LeaveTeamDialogProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: leaveTeam, isLoading: isLeavingTeam } = trpc.team.leaveTeam.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'You have successfully left this team.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to leave this team. Please try again later.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLeavingTeam && setOpen(value)}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{trigger ?? <Button variant="destructive">Leave team</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Are you sure?</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
You are about to leave the following team.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="bg-accent/50 rounded-lg px-4 py-2">
|
|
||||||
<div className="flex max-w-xs items-center gap-2">
|
|
||||||
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
|
|
||||||
<AvatarFallback className="text-xs text-gray-400">
|
|
||||||
{teamName.slice(0, 1).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="flex flex-col text-sm">
|
|
||||||
<span className="text-foreground/80 font-semibold">{teamName}</span>
|
|
||||||
<span className="text-muted-foreground">{TEAM_MEMBER_ROLE_MAP[role]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset disabled={isLeavingTeam}>
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isLeavingTeam}
|
|
||||||
onClick={async () => leaveTeam({ teamId })}
|
|
||||||
>
|
|
||||||
Leave
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Loader } 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 {
|
|
||||||
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 TransferTeamDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
ownerUserId: number;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TransferTeamDialog({
|
|
||||||
trigger,
|
|
||||||
teamId,
|
|
||||||
teamName,
|
|
||||||
ownerUserId,
|
|
||||||
}: TransferTeamDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { mutateAsync: requestTeamOwnershipTransfer } =
|
|
||||||
trpc.team.requestTeamOwnershipTransfer.useMutation();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data,
|
|
||||||
refetch: refetchTeamMembers,
|
|
||||||
isLoading: loadingTeamMembers,
|
|
||||||
isLoadingError: loadingTeamMembersError,
|
|
||||||
} = trpc.team.getTeamMembers.useQuery({
|
|
||||||
teamId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const confirmTransferMessage = `transfer ${teamName}`;
|
|
||||||
|
|
||||||
const ZDeleteTeamFormSchema = z.object({
|
|
||||||
teamName: z.literal(confirmTransferMessage, {
|
|
||||||
errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
|
|
||||||
}),
|
|
||||||
newOwnerUserId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof ZDeleteTeamFormSchema>>({
|
|
||||||
resolver: zodResolver(ZDeleteTeamFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
teamName: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ newOwnerUserId }: z.infer<typeof ZDeleteTeamFormSchema>) => {
|
|
||||||
try {
|
|
||||||
await requestTeamOwnershipTransfer({
|
|
||||||
teamId,
|
|
||||||
newOwnerUserId: Number.parseInt(newOwnerUserId),
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'An email requesting the transfer of this team has been sent.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
duration: 10000,
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to request a transfer of this team. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
|
||||||
Transfer team
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
{teamMembers && teamMembers.length > 0 ? (
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Transfer team</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
Transfer ownership of this team to a selected team member.
|
|
||||||
</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>New team owner</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}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="teamName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
Confirm by typing{' '}
|
|
||||||
<span className="text-destructive">{confirmTransferMessage}</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-lg bg-gray-50/70 p-4">
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
The selected team member will receive an email which they must accept before the
|
|
||||||
team is transferred.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
|
||||||
Transfer
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
) : (
|
|
||||||
<DialogContent 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
|
|
||||||
? 'An error occurred while loading team members. Please try again later.'
|
|
||||||
: 'You must have at least one other team member to transfer ownership.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import type { TeamEmail } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import {
|
|
||||||
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 AddTeamEmailDialogProps = {
|
|
||||||
teamEmail: TeamEmail;
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const ZUpdateTeamEmailFormSchema = z.object({
|
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
|
||||||
|
|
||||||
export default function UpdateTeamEmailDialog({
|
|
||||||
teamEmail,
|
|
||||||
trigger,
|
|
||||||
...props
|
|
||||||
}: AddTeamEmailDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<TUpdateTeamEmailFormSchema>({
|
|
||||||
resolver: zodResolver(ZUpdateTeamEmailFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: teamEmail.name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updateTeamEmail({
|
|
||||||
teamId: teamEmail.teamId,
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Team email was updated.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
router.refresh();
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting update the team email. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [open, form]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? (
|
|
||||||
<Button variant="outline" className="bg-background">
|
|
||||||
Update team email
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Update team email</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
To change the email you must remove and add a new email address.
|
|
||||||
</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>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" placeholder="eg. Legal" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel required>Email</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" value={teamEmail.email} disabled={true} />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
|
|
||||||
<DialogFooter className="space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
|
||||||
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 UpdateTeamMemberDialogProps = {
|
|
||||||
trigger?: React.ReactNode;
|
|
||||||
teamId: number;
|
|
||||||
teamMemberId: number;
|
|
||||||
teamMemberName: string;
|
|
||||||
teamMemberRole: TeamMemberRole;
|
|
||||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
|
||||||
|
|
||||||
export const ZUpdateTeamMemberFormSchema = z.object({
|
|
||||||
role: z.nativeEnum(TeamMemberRole),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ZUpdateTeamMemberSchema = z.infer<typeof ZUpdateTeamMemberFormSchema>;
|
|
||||||
|
|
||||||
export default function UpdateTeamMemberDialog({
|
|
||||||
trigger,
|
|
||||||
teamId,
|
|
||||||
teamMemberId,
|
|
||||||
teamMemberName,
|
|
||||||
teamMemberRole,
|
|
||||||
...props
|
|
||||||
}: UpdateTeamMemberDialogProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm<ZUpdateTeamMemberSchema>({
|
|
||||||
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
role: teamMemberRole,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
|
|
||||||
try {
|
|
||||||
await updateTeamMember({
|
|
||||||
teamId,
|
|
||||||
teamMemberId,
|
|
||||||
data: {
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: `You have updated ${teamMemberName}.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to update this team member. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
{...props}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
|
||||||
>
|
|
||||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
|
||||||
{trigger ?? <Button variant="secondary">Update team member</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Update team member</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription className="mt-4">
|
|
||||||
You are currently updating <span className="font-bold">{teamMemberName}.</span>
|
|
||||||
</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>Role</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select {...field} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
|
|
||||||
<SelectContent className="w-full" position="popper">
|
|
||||||
{Object.values(TeamMemberRole).map((role) => (
|
|
||||||
<SelectItem key={role} value={role}>
|
|
||||||
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-4 space-x-4">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
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';
|
|
||||||
|
|
||||||
export type UpdateTeamDialogProps = {
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
teamUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ZUpdateTeamFormSchema = z.object({
|
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
|
||||||
url: z.string().min(1, 'Please enter a value.'), // Todo: Teams - Restrict certain symbols.
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
|
|
||||||
|
|
||||||
export default function UpdateTeamForm({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(ZUpdateTeamFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: teamName,
|
|
||||||
url: teamUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
|
|
||||||
try {
|
|
||||||
await updateTeam({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
url,
|
|
||||||
},
|
|
||||||
teamId,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Your team has been successfully updated.',
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.reset({
|
|
||||||
name,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (url !== teamUrl) {
|
|
||||||
router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const error = AppError.parseError(err);
|
|
||||||
|
|
||||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
|
||||||
form.setError('url', {
|
|
||||||
type: 'manual',
|
|
||||||
message: 'This URL is already in use.',
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'An unknown error occurred',
|
|
||||||
variant: 'destructive',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to update your team. Please try again later.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>Team Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="mt-4">
|
|
||||||
<FormLabel required>Team URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input className="bg-background" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
{!form.formState.errors.url && (
|
|
||||||
<span className="text-foreground/50 text-xs font-normal">
|
|
||||||
{field.value
|
|
||||||
? `${WEBAPP_BASE_URL}/t/${field.value}`
|
|
||||||
: 'A unique URL to identify your team'}
|
|
||||||
</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()}>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="transition-opacity"
|
|
||||||
disabled={!form.formState.isDirty}
|
|
||||||
loading={form.formState.isSubmitting}
|
|
||||||
>
|
|
||||||
Update team
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useParams, usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
const { getFlag } = useFeatureFlags();
|
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
|
||||||
|
|
||||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
|
||||||
<Link href={`/t/${teamUrl}/settings`}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname === `/t/${teamUrl}/settings` && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<User className="mr-2 h-5 w-5" />
|
|
||||||
General
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href={`/t/${teamUrl}/settings/members`}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith(`/t/${teamUrl}/settings/members`) && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CreditCard className="mr-2 h-5 w-5" />
|
|
||||||
Members
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
|
||||||
<Link href={`/t/${teamUrl}/settings/billing`}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith(`/t/${teamUrl}/settings/billing`) && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Key className="mr-2 h-5 w-5" />
|
|
||||||
Billing
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
// Todo: Teams
|
|
||||||
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|
||||||
// const pathname = usePathname();
|
|
||||||
|
|
||||||
// const { getFlag } = useFeatureFlags();
|
|
||||||
|
|
||||||
// const isBillingEnabled = getFlag('app_billing');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
todo
|
|
||||||
{/* <Link href="/settings/profile">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/profile') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<User className="mr-2 h-5 w-5" />
|
|
||||||
Profile
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link href="/settings/password">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Key className="mr-2 h-5 w-5" />
|
|
||||||
Password
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isBillingEnabled && (
|
|
||||||
<Link href="/settings/billing">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-start',
|
|
||||||
pathname?.startsWith('/settings/billing') && 'bg-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CreditCard className="mr-2 h-5 w-5" />
|
|
||||||
Billing
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)} */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { File } from 'lucide-react';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
export type TeamBillingInvoicesDataTableProps = {
|
|
||||||
teamId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TeamBillingInvoicesDataTable({
|
|
||||||
teamId,
|
|
||||||
}: TeamBillingInvoicesDataTableProps) {
|
|
||||||
const {
|
|
||||||
data: result,
|
|
||||||
isLoading,
|
|
||||||
isInitialLoading,
|
|
||||||
isLoadingError,
|
|
||||||
} = trpc.team.findTeamInvoices.useQuery(
|
|
||||||
{
|
|
||||||
teamId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatCurrency = (currency: string, amount: number) => {
|
|
||||||
const formatter = new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
});
|
|
||||||
|
|
||||||
return formatter.format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = {
|
|
||||||
data: result?.data ?? [],
|
|
||||||
perPage: 100,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Invoice',
|
|
||||||
accessorKey: 'created',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex max-w-xs items-center gap-2">
|
|
||||||
<File className="h-6 w-6" />
|
|
||||||
|
|
||||||
<div className="flex flex-col text-sm">
|
|
||||||
<span className="text-foreground/80 font-semibold">
|
|
||||||
{DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{row.original.quantity} {row.original.quantity > 1 ? 'Seats' : 'Seat'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Status',
|
|
||||||
accessorKey: 'status',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const { status, paid } = row.original;
|
|
||||||
if (!status) {
|
|
||||||
return paid ? 'Paid' : 'Unpaid';
|
|
||||||
}
|
|
||||||
|
|
||||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Amount',
|
|
||||||
accessorKey: 'total',
|
|
||||||
cell: ({ row }) => formatCurrency(row.original.currency, row.original.total / 100),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
disabled={typeof row.original.hostedInvoicePdf !== 'string'}
|
|
||||||
>
|
|
||||||
<Link href={row.original.hostedInvoicePdf ?? ''} target="_blank">
|
|
||||||
View
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
disabled={typeof row.original.hostedInvoicePdf !== 'string'}
|
|
||||||
>
|
|
||||||
<Link href={row.original.invoicePdf ?? ''} target="_blank">
|
|
||||||
Download
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading && isInitialLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/3 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-7 w-7 flex-shrink-0 rounded" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-row justify-end space-x-2">
|
|
||||||
<Skeleton className="h-10 w-20 rounded" />
|
|
||||||
<Skeleton className="h-10 w-16 rounded" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { History, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
export type TeamMemberInvitesDataTableProps = {
|
|
||||||
teamId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TeamMemberInvitesDataTable({ teamId }: TeamMemberInvitesDataTableProps) {
|
|
||||||
const searchParams = useSearchParams()!;
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
|
||||||
Object.fromEntries(searchParams ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
|
||||||
trpc.team.findTeamMemberInvites.useQuery(
|
|
||||||
{
|
|
||||||
teamId,
|
|
||||||
term: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: resendTeamMemberInvitation } =
|
|
||||||
trpc.team.resendTeamMemberInvitation.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Invitation has been resent',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'Unable to resend invitation. Please try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamMemberInvitations } =
|
|
||||||
trpc.team.deleteTeamMemberInvitations.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Invitation has been deleted',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description: 'Unable to delete invitation. Please try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Team Member',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return (
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={row.original.email.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">{row.original.email}</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Role',
|
|
||||||
accessorKey: 'role',
|
|
||||||
cell: ({ row }) => TEAM_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Invited At',
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={async () =>
|
|
||||||
resendTeamMemberInvitation({
|
|
||||||
teamId,
|
|
||||||
invitationId: row.original.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<History className="mr-2 h-4 w-4" />
|
|
||||||
Resend
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={async () =>
|
|
||||||
deleteTeamMemberInvitations({
|
|
||||||
teamId,
|
|
||||||
invitationIds: [row.original.id],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Remove
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading && isInitialLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/2 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
<Skeleton className="ml-2 h-4 w-1/3 max-w-[10rem]" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-6 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
import DeleteTeamMemberDialog from '../dialogs/delete-team-member-dialog';
|
|
||||||
import UpdateTeamMemberDialog from '../dialogs/update-team-member-dialog';
|
|
||||||
|
|
||||||
export type TeamMembersDataTableProps = {
|
|
||||||
teamOwnerUserId: number;
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TeamMembersDataTable({
|
|
||||||
teamOwnerUserId,
|
|
||||||
teamId,
|
|
||||||
teamName,
|
|
||||||
}: TeamMembersDataTableProps) {
|
|
||||||
const searchParams = useSearchParams()!;
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
|
||||||
Object.fromEntries(searchParams ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery(
|
|
||||||
{
|
|
||||||
teamId,
|
|
||||||
term: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Team Member',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const avatarFallbackText = row.original.user.name
|
|
||||||
? extractInitials(row.original.user.name)
|
|
||||||
: row.original.user.email.slice(0, 1).toUpperCase();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={avatarFallbackText}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">{row.original.user.name}</span>
|
|
||||||
}
|
|
||||||
secondaryText={row.original.user.email}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Role',
|
|
||||||
accessorKey: 'role',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
teamOwnerUserId === row.original.userId
|
|
||||||
? 'Owner'
|
|
||||||
: TEAM_MEMBER_ROLE_MAP[row.original.role],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Member Since',
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger>
|
|
||||||
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<UpdateTeamMemberDialog
|
|
||||||
teamId={row.original.teamId}
|
|
||||||
teamMemberId={row.original.id}
|
|
||||||
teamMemberName={row.original.user.name ?? ''}
|
|
||||||
teamMemberRole={row.original.role}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem
|
|
||||||
disabled={teamOwnerUserId === row.original.userId}
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
title={
|
|
||||||
teamOwnerUserId === row.original.userId
|
|
||||||
? 'You cannot update the team owner role'
|
|
||||||
: 'Update team member role'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
|
||||||
Update role
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DeleteTeamMemberDialog
|
|
||||||
teamId={teamId}
|
|
||||||
teamName={teamName}
|
|
||||||
teamMemberId={row.original.id}
|
|
||||||
teamMemberName={row.original.user.name ?? ''}
|
|
||||||
teamMemberEmail={row.original.user.email}
|
|
||||||
trigger={
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
disabled={teamOwnerUserId === row.original.userId}
|
|
||||||
title={
|
|
||||||
teamOwnerUserId === row.original.userId
|
|
||||||
? 'You cannot remove the team owner'
|
|
||||||
: 'Remove team member'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Remove
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading && isInitialLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/2 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-6 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import TeamMemberInvitesDataTable from '~/components/(teams)/tables/team-member-invites-data-table';
|
|
||||||
import TeamMembersDataTable from '~/components/(teams)/tables/team-members-data-table';
|
|
||||||
|
|
||||||
export type TeamsMemberPageDataTableProps = {
|
|
||||||
teamId: number;
|
|
||||||
teamName: string;
|
|
||||||
teamOwnerUserId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TeamsMemberPageDataTable({
|
|
||||||
teamId,
|
|
||||||
teamName,
|
|
||||||
teamOwnerUserId,
|
|
||||||
}: TeamsMemberPageDataTableProps) {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
|
||||||
|
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
|
||||||
|
|
||||||
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle debouncing the search query.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pathname) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams?.toString());
|
|
||||||
|
|
||||||
params.set('query', debouncedSearchQuery);
|
|
||||||
|
|
||||||
if (debouncedSearchQuery === '') {
|
|
||||||
params.delete('query');
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`);
|
|
||||||
}, [debouncedSearchQuery, pathname, router, searchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
|
||||||
<Input
|
|
||||||
defaultValue={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger className="min-w-[60px]" value="members" asChild>
|
|
||||||
<Link href={pathname ?? '/'}>All</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
|
|
||||||
<Link href={`${pathname}?tab=invites`}>Pending</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentTab === 'invites' ? (
|
|
||||||
<TeamMemberInvitesDataTable key="invites" teamId={teamId} />
|
|
||||||
) : (
|
|
||||||
<TeamMembersDataTable
|
|
||||||
key="members"
|
|
||||||
teamId={teamId}
|
|
||||||
teamName={teamName}
|
|
||||||
teamOwnerUserId={teamOwnerUserId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { TEAM_MEMBER_ROLE_MAP, canExecuteTeamAction } from '@documenso/lib/constants/teams';
|
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
import LeaveTeamDialog from '../dialogs/leave-team-dialog';
|
|
||||||
|
|
||||||
export default function UserTeamsDataTable() {
|
|
||||||
const searchParams = useSearchParams()!;
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
|
||||||
Object.fromEntries(searchParams ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeams.useQuery(
|
|
||||||
{
|
|
||||||
term: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Team',
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Link href={`/t/${row.original.url}`} scroll={false}>
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
|
||||||
}
|
|
||||||
secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Role',
|
|
||||||
accessorKey: 'role',
|
|
||||||
cell: ({ row }) =>
|
|
||||||
row.original.ownerUserId === row.original.currentTeamMember.userId
|
|
||||||
? 'Owner'
|
|
||||||
: TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Member Since',
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
{canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && (
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link href={`/t/${row.original.url}/settings`}>Manage</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<LeaveTeamDialog
|
|
||||||
teamId={row.original.id}
|
|
||||||
teamName={row.original.name}
|
|
||||||
role={row.original.currentTeamMember.role}
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={row.original.ownerUserId === row.original.currentTeamMember.userId}
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
Leave
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading && isInitialLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/3 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-20 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-row justify-end space-x-2">
|
|
||||||
<Skeleton className="h-10 w-20 rounded" />
|
|
||||||
<Skeleton className="h-10 w-16 rounded" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import UserTeamsDataTable from './user-teams-data-table';
|
|
||||||
import UserTeamsPendingDataTable from './user-teams-pending-data-table';
|
|
||||||
|
|
||||||
export default function UserTeamsPageDataTable() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
|
||||||
|
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
|
||||||
|
|
||||||
const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active';
|
|
||||||
|
|
||||||
const { data } = trpc.team.findTeamsPending.useQuery(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle debouncing the search query.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pathname) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams?.toString());
|
|
||||||
|
|
||||||
params.set('query', debouncedSearchQuery);
|
|
||||||
|
|
||||||
if (debouncedSearchQuery === '') {
|
|
||||||
params.delete('query');
|
|
||||||
}
|
|
||||||
|
|
||||||
router.push(`${pathname}?${params.toString()}`);
|
|
||||||
}, [debouncedSearchQuery, pathname, router, searchParams]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
|
||||||
<Input
|
|
||||||
defaultValue={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger className="min-w-[60px]" value="active" asChild>
|
|
||||||
<Link href={pathname ?? '/'}>Active</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
|
|
||||||
<TabsTrigger className="min-w-[60px]" value="pending" asChild>
|
|
||||||
<Link href={`${pathname}?tab=pending`}>
|
|
||||||
Pending
|
|
||||||
{data && data.count > 0 && (
|
|
||||||
<span className="ml-1 hidden opacity-50 md:inline-block">{data.count}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{currentTab === 'pending' ? <UserTeamsPendingDataTable /> : <UserTeamsDataTable />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
|
||||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
|
||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
|
||||||
import { TableCell } from '@documenso/ui/primitives/table';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
|
||||||
|
|
||||||
export default function UserTeamsPendingDataTable() {
|
|
||||||
const searchParams = useSearchParams()!;
|
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
|
||||||
Object.fromEntries(searchParams ?? []),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [mutatingTeamIds, setMutatingTeamIds] = useState<{ [id: number]: 'checkout' | 'delete' }>(
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery(
|
|
||||||
{
|
|
||||||
term: parsedSearchParams.query,
|
|
||||||
page: parsedSearchParams.page,
|
|
||||||
perPage: parsedSearchParams.perPage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keepPreviousData: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: createTeamPendingCheckout } =
|
|
||||||
trpc.team.createTeamPendingCheckout.useMutation({
|
|
||||||
onSettled: (_data, _error, { pendingTeamId }) => removeIdFromMutatingTeamIds(pendingTeamId),
|
|
||||||
onMutate: ({ pendingTeamId }) => addIdToMutatingTeamIds(pendingTeamId, 'checkout'),
|
|
||||||
onSuccess: (checkoutUrl) => window.open(checkoutUrl, '_blank'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: deleteTeamPending } = trpc.team.deleteTeamPending.useMutation({
|
|
||||||
onSettled: (_data, _error, { pendingTeamId }) => removeIdFromMutatingTeamIds(pendingTeamId),
|
|
||||||
onMutate: ({ pendingTeamId }) => addIdToMutatingTeamIds(pendingTeamId, 'delete'),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Success',
|
|
||||||
description: 'Pending team deleted.',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast({
|
|
||||||
title: 'Something went wrong',
|
|
||||||
description:
|
|
||||||
'We encountered an unknown error while attempting to delete the pending team. Please try again later.',
|
|
||||||
duration: 10000,
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
|
||||||
updateSearchParams({
|
|
||||||
page,
|
|
||||||
perPage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addIdToMutatingTeamIds = (pendingTeamId: number, type: 'checkout' | 'delete') => {
|
|
||||||
setMutatingTeamIds((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[pendingTeamId]: type,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeIdFromMutatingTeamIds = (pendingTeamId: number) => {
|
|
||||||
setMutatingTeamIds((prev) => {
|
|
||||||
const ids = { ...prev };
|
|
||||||
delete ids[pendingTeamId];
|
|
||||||
return ids;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = data ?? {
|
|
||||||
data: [],
|
|
||||||
perPage: 10,
|
|
||||||
currentPage: 1,
|
|
||||||
totalPages: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataTable
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
header: 'Team',
|
|
||||||
accessorKey: 'name',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<AvatarWithText
|
|
||||||
avatarClass="h-12 w-12"
|
|
||||||
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
|
||||||
primaryText={
|
|
||||||
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
|
||||||
}
|
|
||||||
secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: 'Created on',
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex justify-end space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={Boolean(mutatingTeamIds[row.original.id])}
|
|
||||||
loading={mutatingTeamIds[row.original.id] === 'checkout'}
|
|
||||||
onClick={async () => createTeamPendingCheckout({ pendingTeamId: row.original.id })}
|
|
||||||
>
|
|
||||||
Pay
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={Boolean(mutatingTeamIds[row.original.id])}
|
|
||||||
loading={mutatingTeamIds[row.original.id] === 'delete'}
|
|
||||||
onClick={async () => deleteTeamPending({ pendingTeamId: row.original.id })}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
data={results.data}
|
|
||||||
perPage={results.perPage}
|
|
||||||
currentPage={results.currentPage}
|
|
||||||
totalPages={results.totalPages}
|
|
||||||
onPaginationChange={onPaginationChange}
|
|
||||||
error={{
|
|
||||||
enable: isLoadingError,
|
|
||||||
}}
|
|
||||||
skeleton={{
|
|
||||||
enable: isLoading && isInitialLoading,
|
|
||||||
rows: 3,
|
|
||||||
component: (
|
|
||||||
<>
|
|
||||||
<TableCell className="w-1/3 py-4 pr-4">
|
|
||||||
<div className="flex w-full flex-row items-center">
|
|
||||||
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
|
||||||
|
|
||||||
<div className="ml-2 flex flex-grow flex-col">
|
|
||||||
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
|
||||||
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Skeleton className="h-4 w-12 rounded-full" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex flex-row justify-end space-x-2">
|
|
||||||
<Skeleton className="h-10 w-16 rounded" />
|
|
||||||
<Skeleton className="h-10 w-20 rounded" />
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
|
||||||
</DataTable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,9 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
|||||||
|
|
||||||
export type SignInFormProps = {
|
export type SignInFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignInForm = ({ className, initialEmail }: SignInFormProps) => {
|
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@@ -62,7 +61,7 @@ export const SignInForm = ({ className, initialEmail }: SignInFormProps) => {
|
|||||||
|
|
||||||
const form = useForm<TSignInFormSchema>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: initialEmail ?? '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
totpCode: '',
|
totpCode: '',
|
||||||
backupCode: '',
|
backupCode: '',
|
||||||
|
|||||||
@@ -37,17 +37,16 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
|||||||
|
|
||||||
export type SignUpFormProps = {
|
export type SignUpFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({ className, initialEmail }: SignUpFormProps) => {
|
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: '',
|
name: '',
|
||||||
email: initialEmail ?? '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
signature: '',
|
||||||
},
|
},
|
||||||
|
|||||||
10
apps/web/src/helpers/truncate-title.ts
Normal file
10
apps/web/src/helpers/truncate-title.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const truncateTitle = (title: string, maxLength: number = 16) => {
|
||||||
|
if (title.length <= maxLength) {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = title.slice(0, maxLength / 2);
|
||||||
|
const end = title.slice(-maxLength / 2);
|
||||||
|
|
||||||
|
return `${start}.....${end}`;
|
||||||
|
};
|
||||||
@@ -1,40 +1,8 @@
|
|||||||
import { cookies } from 'next/headers';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getToken } from 'next-auth/jwt';
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
const todoRegex = new RegExp('^/t/[^/]+$');
|
|
||||||
|
|
||||||
export default async function middleware(req: NextRequest) {
|
export default async function middleware(req: NextRequest) {
|
||||||
const preferredTeamUrl = cookies().get('preferred-team-url');
|
|
||||||
|
|
||||||
// Redirect to preferred team if user has selected one.
|
|
||||||
if (
|
|
||||||
!req.url &&
|
|
||||||
(req.nextUrl.pathname === '/' || req.nextUrl.pathname === '/documents') &&
|
|
||||||
preferredTeamUrl?.value
|
|
||||||
) {
|
|
||||||
const redirectUrl = new URL(`/t/${preferredTeamUrl.value}`, req.url);
|
|
||||||
|
|
||||||
return NextResponse.redirect(redirectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect `/t/<team_url>` to `/t/<team_url>/documents`
|
|
||||||
if (todoRegex.test(req.nextUrl.pathname)) {
|
|
||||||
const redirectUrl = new URL(`${req.nextUrl.pathname}/documents`, req.url);
|
|
||||||
|
|
||||||
const response = NextResponse.redirect(redirectUrl);
|
|
||||||
response.cookies.set('preferred-team-url', req.nextUrl.pathname.replace('/t/', ''));
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.nextUrl.pathname === '/t') {
|
|
||||||
const redirectUrl = new URL('/settings/teams', req.url);
|
|
||||||
|
|
||||||
return NextResponse.redirect(redirectUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.nextUrl.pathname === '/') {
|
if (req.nextUrl.pathname === '/') {
|
||||||
const redirectUrl = new URL('/documents', req.url);
|
const redirectUrl = new URL('/documents', req.url);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Session } from 'next-auth';
|
import type { Session } from 'next-auth';
|
||||||
import { SessionProvider } from 'next-auth/react';
|
import { SessionProvider } from 'next-auth/react';
|
||||||
|
|
||||||
export type NextAuthProviderProps = {
|
export type NextAuthProviderProps = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
/** @type {import('lint-staged').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
'**/*.{ts,tsx,cts,mts}': ['eslint --fix'],
|
'**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`,
|
||||||
'**/*.{js,jsx,cjs,mjs}': ['prettier --write'],
|
'**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`,
|
||||||
'**/*.{yml,mdx}': ['prettier --write'],
|
'**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`,
|
||||||
'**/*/package.json': ['npm run precommit'],
|
'**/*/package.json': 'npm run precommit',
|
||||||
};
|
};
|
||||||
|
|||||||
1012
package-lock.json
generated
1012
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
BIN
packages/assets/fonts/dancing-script.ttf
Normal file
BIN
packages/assets/fonts/dancing-script.ttf
Normal file
Binary file not shown.
@@ -71,7 +71,6 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
|||||||
const documents = await prisma.document.count({
|
const documents = await prisma.document.count({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: null,
|
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import type Stripe from 'stripe';
|
|
||||||
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
export type GetCheckoutSessionOptions = {
|
export type GetCheckoutSessionOptions = {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
priceId: string;
|
priceId: string;
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
subscriptionMetadata?: Stripe.Metadata;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getCheckoutSession = async ({
|
export const getCheckoutSession = async ({
|
||||||
customerId,
|
customerId,
|
||||||
priceId,
|
priceId,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
subscriptionMetadata,
|
|
||||||
}: GetCheckoutSessionOptions) => {
|
}: GetCheckoutSessionOptions) => {
|
||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
@@ -30,9 +26,6 @@ export const getCheckoutSession = async ({
|
|||||||
],
|
],
|
||||||
success_url: `${returnUrl}?success=true`,
|
success_url: `${returnUrl}?success=true`,
|
||||||
cancel_url: `${returnUrl}?canceled=true`,
|
cancel_url: `${returnUrl}?canceled=true`,
|
||||||
subscription_data: {
|
|
||||||
metadata: subscriptionMetadata,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return session.url;
|
return session.url;
|
||||||
|
|||||||
@@ -78,14 +78,6 @@ export const getStripeCustomerByUser = async (user: User) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStripeCustomerIdByUser = async (user: User) => {
|
|
||||||
if (user.customerId !== null) {
|
|
||||||
return user.customerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
|
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
|
||||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||||
customer: stripeCustomerId,
|
customer: stripeCustomerId,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
|
|
||||||
export type GetTeamInvoicesOptions = {
|
|
||||||
teamId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTeamInvoices = async ({ teamId }: GetTeamInvoicesOptions) => {
|
|
||||||
const teamSubscriptions = await stripe.subscriptions.search({
|
|
||||||
limit: 100,
|
|
||||||
query: `metadata["teamId"]:"${teamId}"`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscriptionIds = teamSubscriptions.data.map((subscription) => subscription.id);
|
|
||||||
|
|
||||||
if (subscriptionIds.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await stripe.invoices.search({
|
|
||||||
query: subscriptionIds.map((id) => `subscription:"${id}"`).join(' OR '),
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import type Stripe from 'stripe';
|
|
||||||
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
|
||||||
import {
|
|
||||||
getTeamSeatPriceId,
|
|
||||||
isSomeSubscriptionsActiveAndCommunityPlan,
|
|
||||||
} from '@documenso/lib/utils/billing';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import type { Subscription, Team, User } from '@documenso/prisma/client';
|
|
||||||
|
|
||||||
import { getStripeCustomerByUser } from './get-customer';
|
|
||||||
|
|
||||||
type TransferStripeSubscriptionOptions = {
|
|
||||||
user: User & { Subscription: Subscription[] };
|
|
||||||
team: Team;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transfer the Stripe Team seats subscription from one user to another.
|
|
||||||
*
|
|
||||||
* Will create a new subscription for the new owner and cancel the old one.
|
|
||||||
*
|
|
||||||
* Returns the new subscription, null if no subscription is needed (for community plan).
|
|
||||||
*/
|
|
||||||
export const transferTeamSubscription = async ({
|
|
||||||
user,
|
|
||||||
team,
|
|
||||||
}: TransferStripeSubscriptionOptions) => {
|
|
||||||
const teamSeatPriceId = getTeamSeatPriceId();
|
|
||||||
const { stripeCustomer } = await getStripeCustomerByUser(user);
|
|
||||||
|
|
||||||
const newOwnerHasCommunityPlan = isSomeSubscriptionsActiveAndCommunityPlan(user.Subscription);
|
|
||||||
const currentTeamSubscriptionId = team.subscriptionId;
|
|
||||||
|
|
||||||
let oldSubscription: Stripe.Subscription | null = null;
|
|
||||||
let newSubscription: Stripe.Subscription | null = null;
|
|
||||||
|
|
||||||
if (currentTeamSubscriptionId) {
|
|
||||||
oldSubscription = await stripe.subscriptions.retrieve(currentTeamSubscriptionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const numberOfSeats = await prisma.teamMember.count({
|
|
||||||
where: {
|
|
||||||
teamId: team.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!newOwnerHasCommunityPlan) {
|
|
||||||
let stripeCreateSubscriptionPayload: Stripe.SubscriptionCreateParams = {
|
|
||||||
customer: stripeCustomer.id,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
price: teamSeatPriceId,
|
|
||||||
quantity: numberOfSeats,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
metadata: {
|
|
||||||
teamId: team.id.toString(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// If no payment method is attached to the new owner Stripe customer account, send an
|
|
||||||
// invoice instead.
|
|
||||||
if (!stripeCustomer.invoice_settings.default_payment_method) {
|
|
||||||
stripeCreateSubscriptionPayload = {
|
|
||||||
...stripeCreateSubscriptionPayload,
|
|
||||||
collection_method: 'send_invoice',
|
|
||||||
days_until_due: 7,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
newSubscription = await stripe.subscriptions.create(stripeCreateSubscriptionPayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldSubscription) {
|
|
||||||
try {
|
|
||||||
// Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount.
|
|
||||||
await stripe.subscriptions.update(oldSubscription.id, {
|
|
||||||
items: oldSubscription.items.data.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
quantity: 0,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
await stripe.subscriptions.cancel(oldSubscription.id, {
|
|
||||||
invoice_now: true,
|
|
||||||
prorate: false,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Do not error out since we can't easily undo the transfer.
|
|
||||||
// Todo: Teams - Alert us.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newSubscription;
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user