mirror of
https://github.com/documenso/documenso.git
synced 2026-07-07 19:44:57 +10:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39869c46a3 | |||
| d546907c53 | |||
| 917a1271bf | |||
| ca703fc221 | |||
| f4309c1c83 | |||
| 9d626473c8 |
@@ -77,6 +77,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_TEAM_SEAT_PRICE_ID=
|
||||
|
||||
# [[FEATURES]]
|
||||
# OPTIONAL: Leave blank to disable PostHog and feature flags.
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
name: 'General Improvement Request'
|
||||
description: 'Suggest a minor enhancement or improvement for this project'
|
||||
title: '[Title for your improvement suggestion]'
|
||||
name: 'General Improvement'
|
||||
description: Suggest a minor enhancement or improvement for this project
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Please provide a clear and concise title for your improvement suggestion
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Describe the improvement you are suggesting in detail'
|
||||
description: 'Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.'
|
||||
validations:
|
||||
required: true
|
||||
label: Improvement Description
|
||||
description: Describe the improvement you are suggesting in detail. Explain what specific aspect of the project it addresses or enhances.
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 'Additional Information & Alternatives (optional)'
|
||||
description: 'Are there any additional context or information that might be relevant to the improvement suggestion.'
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: assignee
|
||||
label: Rationale
|
||||
description: Explain why this improvement would be beneficial. Share any context, pain points, or reasons for suggesting this change.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 'Do you want to work on this improvement?'
|
||||
multiple: false
|
||||
options:
|
||||
- 'No'
|
||||
- 'Yes'
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
label: Proposed Solution
|
||||
description: If you have a suggestion for how this improvement could be implemented, describe it here. Include any technical details, design suggestions, or other relevant information.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives (optional)
|
||||
description: Are there any alternative approaches to achieve the same improvement? Describe other ways to address the issue or enhance the project.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any additional context or information that might be relevant to the improvement suggestion.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 'Please check the boxes that apply to this improvement suggestion.'
|
||||
label: Please check the boxes that apply to this improvement suggestion.
|
||||
options:
|
||||
- label: 'I have searched the existing issues and improvement suggestions to avoid duplication.'
|
||||
- label: 'I have provided a clear description of the improvement being suggested.'
|
||||
- label: 'I have explained the rationale behind this improvement.'
|
||||
- label: 'I have included any relevant technical details or design suggestions.'
|
||||
- label: 'I understand that this is a suggestion and that there is no guarantee of implementation.'
|
||||
validations:
|
||||
required: true
|
||||
- label: I have searched the existing issues and improvement suggestions to avoid duplication.
|
||||
- label: I have provided a clear description of the improvement being suggested.
|
||||
- label: I have explained the rationale behind this improvement.
|
||||
- label: I have included any relevant technical details or design suggestions.
|
||||
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||
|
||||
@@ -15,10 +15,11 @@ jobs:
|
||||
- uses: actions/stale@v4
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-pr-stale: 90
|
||||
days-before-issue-stale: 90
|
||||
days-before-issue-close: 180
|
||||
days-before-pr-stale: 30
|
||||
days-before-issue-stale: 30
|
||||
stale-issue-message: 'This issue has not seen activity 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.'
|
||||
exempt-pr-labels: 'WIP,on-hold,needs review'
|
||||
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'
|
||||
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned'
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
>We are nominated for a Product Hunt Gold Kitty 😺✨ and appreciate any support: https://documen.so/kitty
|
||||
|
||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||
|
||||
<p align="center" style="margin-top: 20px">
|
||||
@@ -15,9 +13,9 @@
|
||||
·
|
||||
<a href="https://github.com/documenso/documenso/issues">Issues</a>
|
||||
·
|
||||
<a href="https://documen.so/live">Upcoming Releases</a>
|
||||
<a href="https://github.com/documenso/documenso/milestones">Roadmap</a>
|
||||
·
|
||||
<a href="https://documen.so/roadmap">Roadmap</a>
|
||||
<a href="https://documen.so/launches">Upcoming Launches</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
@@ -117,12 +115,10 @@ To run Documenso locally, you will need
|
||||
|
||||
Want to get up and running quickly? Follow these steps:
|
||||
|
||||
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:
|
||||
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/<your-username>/documenso
|
||||
git clone https://github.com/documenso/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.
|
||||
@@ -156,12 +152,10 @@ npm run d
|
||||
|
||||
Follow these steps to setup Documenso on your local machine:
|
||||
|
||||
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:
|
||||
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/<your-username>/documenso
|
||||
git clone https://github.com/documenso/documenso
|
||||
```
|
||||
|
||||
2. Run `npm i` in the root directory
|
||||
@@ -286,16 +280,12 @@ WantedBy=multi-user.target
|
||||
|
||||
### Railway
|
||||
|
||||
[](https://railway.app/template/bG6D4p)
|
||||
[](https://railway.app/template/DjrRRX)
|
||||
|
||||
### Render
|
||||
|
||||
[](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
|
||||
|
||||
### I'm not receiving any emails when using the developer quickstart.
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
title: Jan 10th Email Provider Security Incident
|
||||
description: On January 10th, 2022, we were notified by our email provider that they had experienced a security incident.
|
||||
authorName: 'Lucas Smith'
|
||||
authorImage: '/blog/blog-author-lucas.png'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-01-17
|
||||
tags:
|
||||
- Security
|
||||
---
|
||||
|
||||
On January 10th, 2024 we were notified by our email provider that a security incident had occurred. This security incident which had started on January 7th led to a bad actor obtaining access to their database which contains ours and other customer’s data.
|
||||
|
||||
We understand that during this security incident the following has been accessed:
|
||||
|
||||
- Email addresses.
|
||||
- Metadata on emails sent excluding the email body.
|
||||
|
||||
While the incident is unfortunate we are pleased with the remediation and the processes that our email provider has put in place to help avoid this kind of situation in the future. Since the incident, our provider has rectified the issue and has engaged a security company to conduct an exhaustive investigation and to help improve their security posture moving forward.
|
||||
|
||||
We remain steadfast in our commitment to our current email provider, and will not be taking any further action with relation to changing providers.
|
||||
|
||||
We are now working with our legal counsel to ensure that we provide the appropriate notice to all our customers in each jurisdiction. If you have any further questions on this incident please feel free to contact our support team at [support@documenso.com](mailto:support@documenso.com).
|
||||
|
||||
We appreciate your ongoing support in this matter.
|
||||
|
||||
You can read more on the incident on our providers blog post below:
|
||||
[https://resend.com/blog/incident-report-for-january-10-2024](https://resend.com/blog/incident-report-for-january-10-2024)
|
||||
@@ -1,115 +0,0 @@
|
||||
---
|
||||
title: Moving from Linear to GitHub & LIVE Roadmap 2.0
|
||||
description: We are leaving linear and are going all in on GitHub. Here is how we do it.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-01-10
|
||||
Tags:
|
||||
- GitHub
|
||||
- Backlog
|
||||
- Roadmap
|
||||
---
|
||||
|
||||
# From Linear to GitHub
|
||||
|
||||
> TLDR; We are leaving Linear and are using only GitHub going forward. We no longer communicate feature timelines, only what we are working on and what's next.
|
||||
|
||||
If you follow us, you know we have been in full-on build mode. We are building, the community is building, it's great. Building is our daily business, so we think a lot about improving our approach to doing it.
|
||||
Our most recent approach is to reduce the number of tools and platforms we use. Every tool we use
|
||||
|
||||
- Reduces the average time you spend on the tool
|
||||
- Reduces your focus
|
||||
- Increases mental load to keep all points of interest in mind
|
||||
|
||||
We thought about where we spend the most time, and hardly surprising: it's GitHub. Not only do we spend a lot of time there, but we also WANT to spend a lot of time there because:
|
||||
|
||||
- It's where the community contributes, and we are all about community
|
||||
- It's where we show the world what we are working on
|
||||
|
||||
# The old structure
|
||||
|
||||
So far, we have been using Linear for our Backlog/ Task Management and synced issues we want to showcase or work on with the community via synclinear.com. Not only did we have our development issues there, but since
|
||||
we have our own resident founding designer, we created a proper design backlog to structure our design workflows.
|
||||
|
||||
# The new structure
|
||||
|
||||
We moved everything to GitHub once we realized our focus was already there. This has a few key benefits:
|
||||
|
||||
- Reducing dilution of attention and time: You can hang out on GitHub without risk of missing much
|
||||
- Putting different aspects of Documenso close to each other: Development, Design, Community
|
||||
- Keep long-term, niche, and very abstract issues out of the main repo so we don't get desensitized by large issue numbers
|
||||
|
||||
To achieve this, we created a few GitHub repositories to host issues, with the main repository remaining the central point of interest, especially for the community.
|
||||
|
||||
## 1. Main Repository - Day to day Issues and the shorter-term roadmap (LIVE Roadmap 2.0)
|
||||
|
||||
> [github.com/documenso/documenso](https://github.com/documenso/documenso)
|
||||
|
||||
Apart from the source code of the Documenso app and website, the main repo houses issues raised by the community and issues where we invite the community to participate.
|
||||
With the overhauling of our issue management, we are also updating our progress communication. While the software and product development process is highly complex,
|
||||
we try to give as much insight into what we do as possible. To that end, we went through 3 phases, three being what we do now.
|
||||
|
||||
1. **One extensive roadmap**: Initially we had one roadmap and were (very) slowly checking off boxes there (via a "Roadmap" milestone). While this is easy, it's also pretty imprecise and not practical as the project grows
|
||||
2. **Estimated releases per quarter**: To give better guidance, we tried communicating our goals for the quarter; a pretty big window we thought we could roughly "hit". While the idea of not being too detailed was good, it is tough to estimate when some significant things are done if you do a lot of minor/ other things in parallel,
|
||||
like working with the community and tuning things you go. Hitting time targets is tricky because there may be better things to do than sticking to that time target. This is always much easier to grasp for the people closely involved. The fallacy is to assume the thing you plan for exists in a vacuum.
|
||||
3. Since we do not want to limit ourselves in choosing the most effective course but still give some insight into what's going on and what's coming up, we updated the live roadmap [https://documen.so/live](https://documen.so/live). It now shows what we are currently working on and what we plan on doing next. We do not provide
|
||||
a specific timeline anymore since we couldn't even if we wanted to. Of course, we set our short-term goals based on what's best for the community. We give updates on the issues being worked on as well as possible.
|
||||
|
||||
## 2. Public Backlog - The longer-term roadmap
|
||||
|
||||
> [github.com/documenso/backlog](https://github.com/documenso/backlog)
|
||||
|
||||
The public backlog houses everything we want to build eventually. We do not provide a specific timeline of when that might happen. If we decide against something, it will be removed from the public backlog, as we consider this our long-term vision for Documenso. If you are interested in something on the roadmap, comment on the issue or post on Discord. This helps us gauge interest in specific features.
|
||||
**Issues in the public backlog are not** available to be worked on. For issues to work on, please check the main repository issues. The issues found here are scoped broader since they are not meant for immediate execution but rather give a sense of where Documenso is going and what we consider part of our domain.
|
||||
|
||||
## 3. Internal Backlog
|
||||
|
||||
> github.com/documenso/backlog-internal
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/gh1.png"
|
||||
width="1260"
|
||||
height="630"
|
||||
alt="GitHub: Development Board"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
Our internal Kanban for development
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
This serves as the direct replacement for our Linear backlog. Here, we manage issues that are either too small or short-term for inclusion in the long-term roadmap, yet too specialized or fundamental to be integrated into the main repository. Our development Kanban board is implemented using a GitHub project.
|
||||
|
||||
## 4. Internal Design Backlog
|
||||
|
||||
> github.com/documenso/design-internal
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/gh2.png"
|
||||
width="1260"
|
||||
height="630"
|
||||
alt="GitHub: Design Board"
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
Our internal Kanban for design
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
This is the design equivalent of the internal backlog. The internal design backlog houses our design projects that include the exploration of new features, detailed UI designs, and improving the platform overall.
|
||||
It's similar to the Kanban board for the development backlog.
|
||||
|
||||
## 5. Public Design Repository
|
||||
|
||||
> [github.com/documenso/backlog-design](https://github.com/documenso/design)
|
||||
|
||||
While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead.
|
||||
We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live).
|
||||
|
||||
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Announcing Pre-Seed and Open Metrics
|
||||
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.
|
||||
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.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
|
||||
@@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
|
||||
|
||||
## 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 contributing 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 contrinuting to Documenso.
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-icons": "^4.11.0",
|
||||
"recharts": "^2.7.2",
|
||||
"sharp": "0.33.1",
|
||||
"sharp": "0.32.5",
|
||||
"typescript": "5.2.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 175 KiB |
@@ -39,7 +39,7 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
return (
|
||||
<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="flex-shrink-0">
|
||||
<div>
|
||||
<Link href="/">
|
||||
<Image
|
||||
src={LogoImage}
|
||||
@@ -64,13 +64,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
|
||||
<div className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2">
|
||||
{FOOTER_LINKS.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm"
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm"
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes, KeyboardEvent } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { HTMLAttributes, KeyboardEvent, useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
@@ -91,10 +90,10 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
}
|
||||
|
||||
if (step === STEP.EMAIL) {
|
||||
return 3;
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
return 3;
|
||||
}, [step]);
|
||||
|
||||
const onNextStepClick = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { randomBytes } from 'crypto';
|
||||
import { buffer } from 'micro';
|
||||
@@ -6,8 +6,7 @@ import { buffer } from 'micro';
|
||||
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 { redis } from '@documenso/lib/server-only/redis';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { updateFile } from '@documenso/lib/universal/upload/update-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"sharp": "0.33.1",
|
||||
"sharp": "0.32.5",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
"uqr": "^0.1.2",
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_DATABASE_URL: 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_WEBHOOK_SECRET: string;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -7,9 +7,9 @@ import Link from 'next/link';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Document, User } from '@documenso/prisma/client';
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document, User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
accessorKey: 'owner',
|
||||
cell: ({ row }) => {
|
||||
const avatarFallbackText = row.original.User.name
|
||||
? recipientInitials(row.original.User.name)
|
||||
? extractInitials(row.original.User.name)
|
||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RoleCombobox } from './role-combobox';
|
||||
|
||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
@@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
<RoleCombobox
|
||||
listValues={roles}
|
||||
onChange={(values: string[]) => onChange(values)}
|
||||
/>
|
||||
|
||||
+2
-4
@@ -14,12 +14,12 @@ import {
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
type ComboboxProps = {
|
||||
type RoleComboboxProps = {
|
||||
listValues: string[];
|
||||
onChange: (_values: string[]) => void;
|
||||
};
|
||||
|
||||
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
export const RoleCombobox = ({ listValues, onChange }: RoleComboboxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||
const dbRoles = Object.values(Role);
|
||||
@@ -78,5 +78,3 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { MultiSelectCombobox };
|
||||
@@ -0,0 +1,104 @@
|
||||
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 type { DocumentData, DocumentMeta, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -29,9 +29,9 @@ export type EditDocumentFormProps = {
|
||||
user: User;
|
||||
document: DocumentWithData;
|
||||
recipients: Recipient[];
|
||||
documentMeta: DocumentMeta | null;
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||
@@ -42,9 +42,9 @@ export const EditDocumentForm = ({
|
||||
document,
|
||||
recipients,
|
||||
fields,
|
||||
documentMeta,
|
||||
user: _user,
|
||||
documentData,
|
||||
documentRootPath,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
@@ -58,8 +58,6 @@ export const EditDocumentForm = ({
|
||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
||||
const { mutateAsync: setPasswordForDocument } =
|
||||
trpc.document.setPasswordForDocument.useMutation();
|
||||
|
||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||
title: {
|
||||
@@ -149,16 +147,14 @@ export const EditDocumentForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message, timezone, dateFormat } = data.meta;
|
||||
const { subject, message } = data.email;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
documentId: document.id,
|
||||
meta: {
|
||||
email: {
|
||||
subject,
|
||||
message,
|
||||
timezone,
|
||||
dateFormat,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -168,7 +164,7 @@ export const EditDocumentForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/documents');
|
||||
router.push(documentRootPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -180,13 +176,6 @@ export const EditDocumentForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onPasswordSubmit = async (password: string) => {
|
||||
await setPasswordForDocument({
|
||||
documentId: document.id,
|
||||
password,
|
||||
});
|
||||
};
|
||||
|
||||
const currentDocumentFlow = documentFlow[step];
|
||||
|
||||
return (
|
||||
@@ -196,13 +185,7 @@ export const EditDocumentForm = ({
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
onPasswordSubmit={onPasswordSubmit}
|
||||
/>
|
||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,20 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
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 { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
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';
|
||||
import DocumentPageComponent from './document-page-component';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
@@ -22,103 +6,6 @@ export type DocumentPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
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, documentMeta } = document;
|
||||
|
||||
if (documentMeta?.password) {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const securePassword = Buffer.from(
|
||||
symmetricDecrypt({
|
||||
key,
|
||||
data: documentMeta.password,
|
||||
}),
|
||||
).toString('utf-8');
|
||||
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
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}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer
|
||||
document={document}
|
||||
key={documentData.id}
|
||||
documentMeta={documentMeta}
|
||||
documentData={documentData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||
return <DocumentPageComponent params={params} />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Download, Edit, Pencil } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
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 { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
@@ -19,9 +20,10 @@ export type DataTableActionButtonProps = {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
teamUrl?: string;
|
||||
};
|
||||
|
||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
export const DataTableActionButton = ({ row, teamUrl }: DataTableActionButtonProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -38,6 +40,8 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
|
||||
const documentsPath = formatDocumentsPath(teamUrl);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
let document: DocumentWithData | null = null;
|
||||
@@ -55,14 +59,28 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
throw Error('No document available');
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title });
|
||||
} catch (err) {
|
||||
const documentBytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([documentBytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
description: 'An error occurred while trying to download file.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@@ -78,7 +96,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
})
|
||||
.with({ isOwner: true, isDraft: true }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
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 { DocumentStatus } from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||
@@ -41,11 +41,11 @@ export type DataTableActionDropdownProps = {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
teamUrl?: string;
|
||||
};
|
||||
|
||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||
export const DataTableActionDropdown = ({ row, teamUrl }: DataTableActionDropdownProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
@@ -64,34 +64,42 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
|
||||
const documentsPath = formatDocumentsPath(teamUrl);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
let document: DocumentWithData | null = null;
|
||||
let document: DocumentWithData | null = null;
|
||||
|
||||
if (!recipient) {
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
}
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadPDF({ documentData, fileName: row.title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'An error occurred while downloading your document.',
|
||||
variant: 'destructive',
|
||||
if (!recipient) {
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpcClient.document.getDocumentByToken.query({
|
||||
token: recipient.token,
|
||||
});
|
||||
}
|
||||
|
||||
const documentData = document?.documentData;
|
||||
|
||||
if (!documentData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentBytes = await getFile(documentData);
|
||||
|
||||
const blob = new Blob([documentBytes], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
|
||||
const link = window.document.createElement('a');
|
||||
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
|
||||
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
|
||||
|
||||
link.click();
|
||||
|
||||
window.URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
@@ -113,7 +121,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
@@ -171,6 +179,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
id={row.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
teamUrl={teamUrl}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
'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,9 +27,15 @@ export type DocumentsDataTableProps = {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
}
|
||||
>;
|
||||
showSenderColumn?: boolean;
|
||||
teamUrl?: string;
|
||||
};
|
||||
|
||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
export const DocumentsDataTable = ({
|
||||
results,
|
||||
showSenderColumn,
|
||||
teamUrl,
|
||||
}: DocumentsDataTableProps) => {
|
||||
const { data: session } = useSession();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
@@ -61,6 +67,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
header: 'Title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'sender',
|
||||
header: 'Sender',
|
||||
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||
},
|
||||
{
|
||||
header: 'Recipient',
|
||||
accessorKey: 'recipient',
|
||||
@@ -79,8 +90,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
(!row.original.deletedAt ||
|
||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DataTableActionButton row={row.original} />
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
<DataTableActionButton teamUrl={teamUrl} row={row.original} />
|
||||
<DataTableActionDropdown teamUrl={teamUrl} row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -90,6 +101,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
columnVisibility={{
|
||||
sender: Boolean(showSenderColumn),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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,5 +1,6 @@
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@@ -16,12 +17,14 @@ type DuplicateDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
teamUrl?: string;
|
||||
};
|
||||
|
||||
export const DuplicateDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
teamUrl,
|
||||
}: DuplicateDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
@@ -37,10 +40,12 @@ export const DuplicateDocumentDialog = ({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const documentsPath = formatDocumentsPath(teamUrl);
|
||||
|
||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: (newId) => {
|
||||
router.push(`/documents/${newId}`);
|
||||
router.push(`${documentsPath}/${newId}`);
|
||||
|
||||
toast({
|
||||
title: 'Document Duplicated',
|
||||
|
||||
@@ -1,114 +1,10 @@
|
||||
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 { 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';
|
||||
import type { DocumentsPageComponentProps } from './documents-page-component';
|
||||
import DocumentsPageComponent from './documents-page-component';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
};
|
||||
searchParams?: DocumentsPageComponentProps['searchParams'];
|
||||
};
|
||||
|
||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
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 inline-block opacity-50">
|
||||
{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>
|
||||
);
|
||||
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
return <DocumentsPageComponent searchParams={searchParams} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -20,12 +20,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
team?: {
|
||||
id: number;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { toast } = useToast();
|
||||
@@ -36,16 +39,6 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
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) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -60,6 +53,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
@@ -74,7 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`/documents/${id}`);
|
||||
router.push(team?.id !== undefined ? `/t/${team.url}/documents/${id}` : `/documents/${id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -101,16 +95,17 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
<DocumentDropzone
|
||||
className="min-h-[40vh]"
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
@@ -119,7 +114,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remaining.documents === 0 && (
|
||||
{team?.id === undefined && remaining.documents === 0 && (
|
||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
@@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const [{ user }, teams] = await Promise.all([
|
||||
getRequiredServerComponentSession(),
|
||||
getTeams({ userId: session.user.id }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider>
|
||||
{!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>
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export const BillingPortalButton = () => {
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||
@@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={async () => handleFetchPortalUrl()}
|
||||
loading={isFetchingPortalUrl}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'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,8 +15,6 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
export type CompletedSigningPageProps = {
|
||||
params: {
|
||||
token?: string;
|
||||
@@ -38,8 +36,6 @@ export default async function CompletedSigningPage({
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
@@ -93,7 +89,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">
|
||||
You have signed
|
||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||
<span className="mt-1.5 block">"{document.title}"</span>
|
||||
</h2>
|
||||
|
||||
{match({ status: document.status, deletedAt: document.deletedAt })
|
||||
|
||||
@@ -6,13 +6,8 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import {
|
||||
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 { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -21,16 +16,9 @@ import { SigningFieldContainer } from './signing-field-container';
|
||||
export type DateFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
recipient: Recipient;
|
||||
dateFormat?: string | null;
|
||||
timezone?: string | null;
|
||||
};
|
||||
|
||||
export const DateField = ({
|
||||
field,
|
||||
recipient,
|
||||
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
|
||||
}: DateFieldProps) => {
|
||||
export const DateField = ({ field, recipient }: DateFieldProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@@ -47,18 +35,12 @@ export const DateField = ({
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
await signFieldWithToken({
|
||||
token: recipient.token,
|
||||
fieldId: field.id,
|
||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
value: '',
|
||||
});
|
||||
|
||||
startTransition(() => router.refresh());
|
||||
@@ -93,13 +75,7 @@ export const DateField = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer
|
||||
field={field}
|
||||
onSign={onSign}
|
||||
onRemove={onRemove}
|
||||
type="Date"
|
||||
tooltipText={isDifferentTime ? tooltipText : undefined}
|
||||
>
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<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" />
|
||||
@@ -111,7 +87,7 @@ export const DateField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
|
||||
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p>
|
||||
)}
|
||||
</SigningFieldContainer>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<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" />
|
||||
|
||||
@@ -34,7 +34,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
|
||||
const { mutateAsync: completeDocumentWithToken } =
|
||||
@@ -49,11 +48,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||
}, [fields]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
validateFieldsInserted(fields);
|
||||
};
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
setValidateUninsertedFields(true);
|
||||
|
||||
@@ -98,11 +92,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
disabled={isSubmitting}
|
||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex flex-1 flex-col')}>
|
||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
@@ -159,7 +149,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||
document={document}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
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 { NextAuthProvider } from '~/providers/next-auth';
|
||||
@@ -12,10 +13,16 @@ export type SigningLayoutProps = {
|
||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
let teams: GetTeamsResponse = [];
|
||||
|
||||
if (user && session) {
|
||||
teams = await getTeams({ userId: user.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<div className="min-h-screen">
|
||||
{user && <AuthenticatedHeader user={user} />}
|
||||
{user && <AuthenticatedHeader user={user} teams={teams} />}
|
||||
|
||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { Recipient } from '@documenso/prisma/client';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
@@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<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" />
|
||||
|
||||
@@ -2,25 +2,18 @@ import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
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 { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { DateField } from './date-field';
|
||||
import { EmailField } from './email-field';
|
||||
import { SigningForm } from './form';
|
||||
@@ -49,14 +42,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
viewedDocument({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData || !recipient) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const { documentData } = document;
|
||||
|
||||
const { user } = await getServerComponentSession();
|
||||
@@ -68,23 +57,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
redirect(`/sign/${token}/complete`);
|
||||
}
|
||||
|
||||
if (documentMeta?.password) {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const securePassword = Buffer.from(
|
||||
symmetricDecrypt({
|
||||
key,
|
||||
data: documentMeta.password,
|
||||
}),
|
||||
).toString('utf-8');
|
||||
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const [recipientSignature] = await getRecipientSignatures({ recipientId: recipient.id });
|
||||
|
||||
if (document.deletedAt) {
|
||||
@@ -105,7 +77,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
>
|
||||
<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}>
|
||||
{truncatedTitle}
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
@@ -120,12 +92,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
/>
|
||||
<LazyPDFViewer key={documentData.id} documentData={documentData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -144,13 +111,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
<NameField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={recipient}
|
||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
/>
|
||||
<DateField key={field.id} field={field} recipient={recipient} />
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Document, Field } from '@documenso/prisma/client';
|
||||
import { Document, Field } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -9,13 +9,10 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
document: Document;
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -23,31 +20,30 @@ export const SignDialog = ({
|
||||
isSubmitting,
|
||||
document,
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
return (
|
||||
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
|
||||
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={fieldsValidated}
|
||||
disabled={!isComplete}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isComplete ? 'Complete' : 'Next field'}
|
||||
Complete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="text-center">
|
||||
<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">
|
||||
You are about to finish signing "{truncatedTitle}". Are you sure?
|
||||
You are about to finish signing "{document.title}". Are you sure?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
|
||||
{isLoading && (
|
||||
<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" />
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
export type SignatureFieldProps = {
|
||||
field: FieldWithSignature;
|
||||
@@ -12,8 +11,6 @@ export type SignatureFieldProps = {
|
||||
children: React.ReactNode;
|
||||
onSign?: () => Promise<void> | void;
|
||||
onRemove?: () => Promise<void> | void;
|
||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
||||
tooltipText?: string | null;
|
||||
};
|
||||
|
||||
export const SigningFieldContainer = ({
|
||||
@@ -22,8 +19,6 @@ export const SigningFieldContainer = ({
|
||||
onSign,
|
||||
onRemove,
|
||||
children,
|
||||
type,
|
||||
tooltipText,
|
||||
}: SignatureFieldProps) => {
|
||||
const onSignFieldClick = async () => {
|
||||
if (field.inserted) {
|
||||
@@ -51,22 +46,7 @@ export const SigningFieldContainer = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
{field.inserted && !loading && (
|
||||
<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}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'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,7 +2,15 @@ import Link from 'next/link';
|
||||
|
||||
import { SignInForm } from '~/components/forms/signin';
|
||||
|
||||
export default function SignInPage() {
|
||||
type SignInPageProps = {
|
||||
searchParams: {
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const email = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
@@ -11,7 +19,7 @@ export default function SignInPage() {
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
|
||||
<SignInForm className="mt-4" />
|
||||
<SignInForm initialEmail={email} className="mt-4" />
|
||||
|
||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
|
||||
@@ -3,11 +3,19 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { SignUpForm } from '~/components/forms/signup';
|
||||
|
||||
export default function SignUpPage() {
|
||||
type SignUpPageProps = {
|
||||
searchParams: {
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const email = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||
@@ -17,7 +25,7 @@ export default function SignUpPage() {
|
||||
signing is within your grasp.
|
||||
</p>
|
||||
|
||||
<SignUpForm className="mt-4" />
|
||||
<SignUpForm initialEmail={email} className="mt-4" />
|
||||
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { StackAvatar } from './stack-avatar';
|
||||
@@ -20,10 +19,6 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const onRecipientClick = () => {
|
||||
if (!recipient.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
@@ -33,22 +28,19 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('my-1 flex items-center gap-2', {
|
||||
'cursor-pointer hover:underline': recipient.token,
|
||||
})}
|
||||
role={recipient.token ? 'button' : undefined}
|
||||
title={recipient.token && 'Click to copy signing link for sending to recipient'}
|
||||
onClick={onRecipientClick}
|
||||
>
|
||||
<div className="my-1 flex cursor-pointer items-center gap-2" onClick={onRecipientClick}>
|
||||
<StackAvatar
|
||||
first={true}
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
<span
|
||||
className="text-muted-foreground text-sm hover:underline"
|
||||
title="Click to copy signing link for sending to recipient"
|
||||
>
|
||||
{recipient.email}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,16 +5,13 @@ import { useCallback, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { Loader, Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import {
|
||||
DOCUMENTS_PAGE_SHORTCUT,
|
||||
SETTINGS_PAGE_SHORTCUT,
|
||||
TEMPLATES_PAGE_SHORTCUT,
|
||||
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
CommandDialog,
|
||||
@@ -42,14 +39,6 @@ const DOCUMENTS_PAGES = [
|
||||
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
||||
];
|
||||
|
||||
const TEMPLATES_PAGES = [
|
||||
{
|
||||
label: 'All templates',
|
||||
path: '/templates',
|
||||
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
|
||||
},
|
||||
];
|
||||
|
||||
const SETTINGS_PAGES = [
|
||||
{
|
||||
label: 'Settings',
|
||||
@@ -67,8 +56,6 @@ export type CommandMenuProps = {
|
||||
|
||||
export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
const { setTheme } = useTheme();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(() => open ?? false);
|
||||
@@ -85,17 +72,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const isOwner = useCallback(
|
||||
(document: Document) => document.userId === session?.user.id,
|
||||
[session?.user.id],
|
||||
);
|
||||
|
||||
const getSigningLink = useCallback(
|
||||
(recipients: Recipient[]) =>
|
||||
`/sign/${recipients.find((r) => r.email === session?.user.email)?.token}`,
|
||||
[session?.user.email],
|
||||
);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searchDocumentsData) {
|
||||
return [];
|
||||
@@ -103,14 +79,15 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
|
||||
return searchDocumentsData.map((document) => ({
|
||||
label: document.title,
|
||||
path: isOwner(document) ? `/documents/${document.id}` : getSigningLink(document.Recipient),
|
||||
path: `/documents/${document.id}`,
|
||||
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||
}));
|
||||
}, [searchDocumentsData, isOwner, getSigningLink]);
|
||||
}, [searchDocumentsData]);
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
|
||||
const toggleOpen = () => {
|
||||
const toggleOpen = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
setIsOpen((isOpen) => !isOpen);
|
||||
onOpenChange?.(!isOpen);
|
||||
|
||||
@@ -148,12 +125,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
|
||||
const goToSettings = useCallback(() => push(SETTINGS_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, { preventDefault: true });
|
||||
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen);
|
||||
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Escape goes to previous page
|
||||
@@ -200,9 +175,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
<CommandGroup heading="Documents">
|
||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Templates">
|
||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Settings">
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
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 { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
@@ -15,9 +16,12 @@ import { ProfileDropdown } from './profile-dropdown';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
user: User;
|
||||
teams: GetTeamsResponse;
|
||||
};
|
||||
|
||||
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
const params = useParams();
|
||||
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,10 +34,18 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const getRootHref = () => {
|
||||
if (typeof params?.teamUrl === 'string') {
|
||||
return `/t/${params.teamUrl}`;
|
||||
}
|
||||
|
||||
return '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
@@ -41,7 +53,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
href={getRootHref()}
|
||||
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" />
|
||||
@@ -50,11 +62,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
<DesktopNav />
|
||||
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<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> */}
|
||||
<ProfileDropdown user={user} teams={teams} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,162 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
FileSpreadsheet,
|
||||
Lock,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
Sun,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { TEAM_MEMBER_ROLE_MAP, canExecuteTeamAction } from '@documenso/lib/constants/teams';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
export type ProfileDropdownProps = {
|
||||
user: User;
|
||||
teams: GetTeamsResponse;
|
||||
};
|
||||
|
||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
const { getFlag } = useFeatureFlags();
|
||||
const { theme, setTheme } = useTheme();
|
||||
export const ProfileDropdown = ({ user, teams: initialTeamsData }: ProfileDropdownProps) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
|
||||
initialData: initialTeamsData,
|
||||
});
|
||||
|
||||
const avatarFallback = user.name
|
||||
? recipientInitials(user.name)
|
||||
: user.email.slice(0, 1).toUpperCase();
|
||||
const teams = teamsQueryResult && teamsQueryResult.length > 0 ? teamsQueryResult : null;
|
||||
|
||||
const isPathTeamUrl = (teamUrl: string) => {
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Profile Dropdown"
|
||||
className="relative h-10 w-10 rounded-full"
|
||||
className="relative flex h-12 flex-row items-center px-2 py-2 focus-visible:ring-0"
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* Todo */}
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(selectedTeam?.name)}
|
||||
primaryText={selectedTeam ? selectedTeam.name : user.name}
|
||||
secondaryText={formatSecondaryAvatarText(selectedTeam)}
|
||||
rightSideComponent={
|
||||
<ChevronsUpDown className="text-muted-foreground ml-auto h-4 w-4" />
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
|
||||
{isUserAdmin && (
|
||||
<DropdownMenuContent
|
||||
className={cn('w-full', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||
align="end"
|
||||
forceMount
|
||||
>
|
||||
{teams ? (
|
||||
<>
|
||||
<DropdownMenuLabel>Personal</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
Admin
|
||||
<Link href="/">
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback()}
|
||||
primaryText={user.name}
|
||||
secondaryText={formatSecondaryAvatarText()}
|
||||
rightSideComponent={
|
||||
!pathname?.startsWith(`/t/`) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator className="mt-2" />
|
||||
|
||||
<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 asChild>
|
||||
<Link href="/settings/profile" className="cursor-pointer">
|
||||
<LucideUser className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
</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
|
||||
) : (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link
|
||||
href="/settings/teams?action=add-team"
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
Create team
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/templates" className="cursor-pointer">
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Templates
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Themes
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<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>
|
||||
{isUserAdmin && (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link href="/admin">Admin panel</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link href="/settings/profile">User settings</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{selectedTeam &&
|
||||
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
|
||||
onSelect={() =>
|
||||
void signOut({
|
||||
className="text-destructive/90 hover:!text-destructive px-4 py-2"
|
||||
onSelect={async () =>
|
||||
signOut({
|
||||
callbackUrl: '/',
|
||||
})
|
||||
}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -38,6 +38,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
'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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'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 type { HTMLAttributes } from 'react';
|
||||
import { HTMLAttributes } from 'react';
|
||||
|
||||
import { CheckCircle2, Clock, File } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
import { DateTime, DateTimeFormatOptions } from 'luxon';
|
||||
|
||||
import { useLocale } from '@documenso/lib/client-only/providers/locale';
|
||||
|
||||
|
||||
@@ -48,9 +48,10 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||
|
||||
export type SignInFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
};
|
||||
|
||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
export const SignInForm = ({ className, initialEmail }: SignInFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||
useState(false);
|
||||
@@ -61,7 +62,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
||||
|
||||
const form = useForm<TSignInFormSchema>({
|
||||
values: {
|
||||
email: '',
|
||||
email: initialEmail ?? '',
|
||||
password: '',
|
||||
totpCode: '',
|
||||
backupCode: '',
|
||||
|
||||
@@ -37,16 +37,17 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||
|
||||
export type SignUpFormProps = {
|
||||
className?: string;
|
||||
initialEmail?: string;
|
||||
};
|
||||
|
||||
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
||||
export const SignUpForm = ({ className, initialEmail }: SignUpFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const form = useForm<TSignUpFormSchema>({
|
||||
values: {
|
||||
name: '',
|
||||
email: '',
|
||||
email: initialEmail ?? '',
|
||||
password: '',
|
||||
signature: '',
|
||||
},
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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,8 +1,40 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
const todoRegex = new RegExp('^/t/[^/]+$');
|
||||
|
||||
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 === '/') {
|
||||
const redirectUrl = new URL('/documents', req.url);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type { Session } from 'next-auth';
|
||||
import { Session } from 'next-auth';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
|
||||
export type NextAuthProviderProps = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** @type {import('lint-staged').Config} */
|
||||
module.exports = {
|
||||
'**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`,
|
||||
'**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`,
|
||||
'**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`,
|
||||
'**/*/package.json': 'npm run precommit',
|
||||
'**/*.{ts,tsx,cts,mts}': ['eslint --fix'],
|
||||
'**/*.{js,jsx,cjs,mjs}': ['prettier --write'],
|
||||
'**/*.{yml,mdx}': ['prettier --write'],
|
||||
'**/*/package.json': ['npm run precommit'],
|
||||
};
|
||||
|
||||
Generated
+353
-662
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user