mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge branch 'main' into feat/add-runtime-env
This commit is contained in:
@ -25,7 +25,7 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/
|
|||||||
# [[E2E Tests]]
|
# [[E2E Tests]]
|
||||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||||
|
|
||||||
# [[STORAGE]]
|
# [[STORAGE]]
|
||||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
@ -74,6 +74,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
|
|||||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
|
||||||
# OPTIONAL: The private key to use for DKIM signing.
|
# OPTIONAL: The private key to use for DKIM signing.
|
||||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
||||||
|
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
|
||||||
|
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
||||||
|
|
||||||
# [[STRIPE]]
|
# [[STRIPE]]
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
|
|||||||
68
apps/marketing/content/blog/why-i-started-documenso.mdx
Normal file
68
apps/marketing/content/blog/why-i-started-documenso.mdx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
title: Why I started Documenso
|
||||||
|
description: I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption and want to help make the internet/ world more open.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2024-02-06
|
||||||
|
Tags:
|
||||||
|
- Founders
|
||||||
|
- Mission
|
||||||
|
- Open Source
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/burgers.jpeg"
|
||||||
|
width="650"
|
||||||
|
height="100"
|
||||||
|
alt="Burgers, drinks on a table between friends."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
Not the burger from the story. But it could be as well, the place is pretty generic.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
> TLDR; I started Documenso because I wanted to build a modern tech company in a growing space with a mission bigger than money, I overpaid for a SSL cert 13 years ago, like encryption, and wanted to help make the world/ Internet more open.
|
||||||
|
|
||||||
|
It's hard to pinpoint when I decided to start Documenso. I first uttered the word "Documenso" while sitting in a restaurant with [Felix](https://twitter.com/flxmgdnz), eating a burger and discussing what's next in late 2022. Shortly after, I sat down with a can of caffeine and started building [Documenso 0.9](https://github.com/documenso/documenso/releases/tag/0.9-developer-preview). Starting Documenso is the most deliberate business decision I ever made. It was deliberate from the personal side and deliberate from the business side.
|
||||||
|
|
||||||
|
Looking at the personal side, I've had some time off and was actively looking for my next move. Looking back, I stumbled into my first company. Less so with the second one, but I joined my co-founders and did not develop the core concept myself. While coming up with Documenso, I was deliberately looking for a few things, based on my previous experiences:
|
||||||
|
|
||||||
|
- An entrepreneurial space that was a big enough opportunity
|
||||||
|
- A huge macro trend, lifting everything in it's space
|
||||||
|
- A mode of working that fits my flow (which, luckily for me, is pretty close to the modern startup/ tech scene)
|
||||||
|
- A more significant impact to be made than just earning lots of money (though there is nothing wrong with that)
|
||||||
|
|
||||||
|
Quick shoutout to everyone feeling even a pinch of imposter syndrome while calling themselves a founder. It was after ten years, slightly after starting Documenso, that I started doing it in my head without cringing. So cut yourself some slack. Considering how long I've been doing this, I would have earned the internal title sooner, and so do you. After grappling with my identity for a second, as is customary for founders, my decision to start this journey came quickly.
|
||||||
|
|
||||||
|
Aside from the personal dimension, I had a clear mindset of what I wanted. The criteria I describe below clicked into place one after another, in no particular order. Having experienced no market demand and a very gritty, grindy market, I was looking for something more fundamental. Something basic, infrastructure-like, with a huge demand. A growing market deeply rooted in the ever-increasing digitalization of the world.
|
||||||
|
|
||||||
|
And to be honest, I just always liked digital signature tools. It's a product that is easy enough to comprehend and build but complex and impactful enough to satisfy a hard need. It's a product you can build very product-driven since the market and domain are well understood. So when asked about what's next for me, I literally said, "Digital, um, let's say… signatures". As it turns out, my first gut feeling was spot on, but how spot on I only realized when I started researching the space. An open source document signing company happens to be the perfect intersection of all the criteria and personal preferences I described above; it's pretty amazing, actually:
|
||||||
|
|
||||||
|
- The global signing market is enormous and rapidly growing
|
||||||
|
- To put it bluntly, the signing space is vast and dominated by one outdated player. Outdated in terms of tech, pricing, and ecosystem
|
||||||
|
- The signing space is also ridiculously opaque for a space based on open web tech, open encryption tech, and open signing standards. Even by closed-source standards
|
||||||
|
- We are currently seeing a renaissance for commercial open source startups, combining venture founder financials with open source mechanics
|
||||||
|
- Rebuilding a fundamental infrastructure as open source with a meaningful scale has a profoundly transformative effect on any space
|
||||||
|
- Working in open source requires being open, cooperative, and inclusive. It also requires quite a bit of context jumping, "going with the flow," and empathy
|
||||||
|
- Apart from fixing the signing space, making Documenso successful would be another domino tile toward open source eating the world, which is great for everyone
|
||||||
|
|
||||||
|
Building a company is so complex it can't be planned out. Basing it on great fundamentals and the expected dynamics is the best founders can do, in my humble opinion. After these fundamental decisions, you are (almost) just along for the ride and need to focus on solving the "conventional" problems of starting a company the best you can. With digital signatures hitting so many points of my personal and professional checklist, this already was a great fit. What got me excited at first, though, apart from the perspective of drinking caffeine and coding, was this:
|
||||||
|
|
||||||
|
Roughly 13 years ago, I was launching my first product. We obviously wanted SSL encryption on the product site, so I had to buy an SSL certificate. ~$200ish, two years validity, from VeriSign, I think. Apart from it being ridiculously complicated to get, it bothered me that we had basically paid $200 for what is essentially a long number someone generated. SSL wasn't even that widespread back then because it was mainly considered important for e-commerce, no wonder considering it cost so much. "Why would I encrypt a blog?". Fast forward to today, and everyone can get a free SSL cert courtesy of [Let's Encrypt](https://letsencrypt.org/) and browsers are basically blocking unencrypted sites. Mostly, it is even built into hosting platforms, so you barely even notice as a developer.
|
||||||
|
|
||||||
|
I had forgotten all about that story until I realized this is where signing is today. A global need fulfilled only by a closed ecosystem, not really state-of-the-art companies, leading to, let's call it, steep prices. I had considered Let's Encrypt a pillar of the open internet for so long that I forgot that they weren't always there. One day, someone said, let's make the internet better. Signing is another domain that should have had an open ecosystem for a long time. Another parallel to that story is the fact that the cryptographic certificates you need for document signing are also stuck in the "pre-Let's Encrypt world." Free document signing certificates via "Let's Sign" are now another to-do on the [long-term roadmap](https://documen.so/roadmap) list for the open signing ecosystem. Effecting this change in any way is a huge driver for me.
|
||||||
|
|
||||||
|
Apart from my personal gripes with the corporate certificate industry, I have always found encryption fascinating. It's such a fundamental force in society when you think about it: Secure Communication, Secure Commerce, and even [internet native, open source money (Bitcoin)](https://github.com/bitcoin/bitcoin) were created using a bit of smart math. All these examples are expressions of very fundamental human behaviors that should be enabled and protected by open infrastructures.
|
||||||
|
|
||||||
|
I never told rthis to anyone before, but since starting Documenso, I realized that I underestimated the impact and importance of open source for quite some time. When I was in University, I distantly remember my mindset of "yeah, open source is nice, but the great, commercially successful products used in the real world are built by closed companies (aka Microsoft)" _shudder_ It was never really a conscious thought, but enough that I started learning MS Silverlight before plain Javascript. It was slowly, over time, that I realized that open web standards are superior to closed ones, and even later, I understood the same holds true for all software. Open source fixes something in the economy I find hard to articulate. I did my best in [Commodifying Signing](https://documenso.com/blog/commodifying-signing).
|
||||||
|
|
||||||
|
To wrap this up, Documenso happens to be the perfect storm of market opportunity, my interests, and my passions. Creating a company in which people want to work for the long term while tackling these issues is a critical side quest of Documenso. This is not only about building the next generation of signing tech; it's also about doing our part to normalize open, healthy, efficient working cultures and tackling relevant problems.
|
||||||
|
|
||||||
|
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions, comments, thoughts or feelings.
|
||||||
|
|
||||||
|
\
|
||||||
|
Best from Hamburg\
|
||||||
|
Timur
|
||||||
BIN
apps/marketing/public/blog/burgers.jpeg
Normal file
BIN
apps/marketing/public/blog/burgers.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
@ -41,7 +41,7 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
<Header className="mx-auto h-16 max-w-screen-xl px-4 md:h-20 lg:px-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mx-auto max-w-screen-xl flex-1 px-4 lg:px-8">{children}</div>
|
<div className="relative max-w-screen-xl flex-1 px-4 sm:mx-auto lg:px-8">{children}</div>
|
||||||
|
|
||||||
<Footer className="bg-background border-muted mt-24 border-t" />
|
<Footer className="bg-background border-muted mt-24 border-t" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -159,6 +159,7 @@ export const SinglePlayerClient = () => {
|
|||||||
readStatus: 'OPENED',
|
readStatus: 'OPENED',
|
||||||
signingStatus: 'NOT_SIGNED',
|
signingStatus: 'NOT_SIGNED',
|
||||||
sendStatus: 'NOT_SENT',
|
sendStatus: 'NOT_SENT',
|
||||||
|
role: 'SIGNER',
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
|
|||||||
@ -404,6 +404,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
|
disabled={isSubmitting}
|
||||||
className="aspect-video w-full rounded-md border"
|
className="aspect-video w-full rounded-md border"
|
||||||
defaultValue={signatureDataUrl || ''}
|
defaultValue={signatureDataUrl || ''}
|
||||||
onChange={setDraftSignatureDataUrl}
|
onChange={setDraftSignatureDataUrl}
|
||||||
|
|||||||
@ -45,6 +45,7 @@
|
|||||||
"sharp": "0.33.1",
|
"sharp": "0.33.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"ua-parser-js": "^1.0.37",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@ -53,7 +54,8 @@
|
|||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7",
|
||||||
|
"@types/ua-parser-js": "^0.7.39"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next-auth": {
|
"next-auth": {
|
||||||
|
|||||||
BIN
apps/web/public/static/add-user.png
Normal file
BIN
apps/web/public/static/add-user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
apps/web/public/static/mail-open-alert.png
Normal file
BIN
apps/web/public/static/mail-open-alert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web/public/static/mail-open.png
Normal file
BIN
apps/web/public/static/mail-open.png
Normal file
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 { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Document, User } from '@documenso/prisma/client';
|
import type { Document, User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
accessorKey: 'owner',
|
accessorKey: 'owner',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const avatarFallbackText = row.original.User.name
|
const avatarFallbackText = row.original.User.name
|
||||||
? recipientInitials(row.original.User.name)
|
? extractInitials(row.original.User.name)
|
||||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -19,7 +19,7 @@ type ComboboxProps = {
|
|||||||
onChange: (_values: string[]) => void;
|
onChange: (_values: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||||
const dbRoles = Object.values(Role);
|
const dbRoles = Object.values(Role);
|
||||||
@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { MultiSelectCombobox };
|
export { MultiSelectRoleCombobox };
|
||||||
@ -18,9 +18,10 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
|
||||||
|
|
||||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||||
|
|
||||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<MultiSelectCombobox
|
<MultiSelectRoleCombobox
|
||||||
listValues={roles}
|
listValues={roles}
|
||||||
onChange={(values: string[]) => onChange(values)}
|
onChange={(values: string[]) => onChange(values)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||||
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
|
|
||||||
import { UsersDataTable } from './data-table-users';
|
import { UsersDataTable } from './data-table-users';
|
||||||
import { search } from './fetch-users.actions';
|
import { search } from './fetch-users.actions';
|
||||||
@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
|||||||
|
|
||||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||||
search(searchString, page, perPage),
|
search(searchString, page, perPage),
|
||||||
getPricesByType('individual'),
|
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const individualPriceIds = individualPrices.map((price) => price.id);
|
const individualPriceIds = individualPrices.map((price) => price.id);
|
||||||
|
|||||||
@ -0,0 +1,131 @@
|
|||||||
|
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
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 DocumentPageViewProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentPageView = async ({ params, team }: DocumentPageViewProps) => {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const documentId = Number(id);
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
|
if (!documentId || Number.isNaN(documentId)) {
|
||||||
|
redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const document = await getDocumentById({
|
||||||
|
id: documentId,
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document || !document.documentData) {
|
||||||
|
redirect(documentRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
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={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}
|
||||||
|
documentMeta={documentMeta}
|
||||||
|
recipients={recipients}
|
||||||
|
fields={fields}
|
||||||
|
documentData={documentData}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -32,6 +32,7 @@ export type EditDocumentFormProps = {
|
|||||||
documentMeta: DocumentMeta | null;
|
documentMeta: DocumentMeta | null;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
|
documentRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||||
@ -45,6 +46,7 @@ export const EditDocumentForm = ({
|
|||||||
documentMeta,
|
documentMeta,
|
||||||
user: _user,
|
user: _user,
|
||||||
documentData,
|
documentData,
|
||||||
|
documentRootPath,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -168,7 +170,7 @@ export const EditDocumentForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/documents');
|
router.push(documentRootPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -218,9 +220,9 @@ export const EditDocumentForm = ({
|
|||||||
<AddTitleFormPartial
|
<AddTitleFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.title}
|
documentFlow={documentFlow.title}
|
||||||
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
document={document}
|
|
||||||
onSubmit={onAddTitleFormSubmit}
|
onSubmit={onAddTitleFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,4 @@
|
|||||||
import Link from 'next/link';
|
import { DocumentPageView } from './document-page-view';
|
||||||
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';
|
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -22,103 +6,6 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||||
const { id } = params;
|
return <DocumentPageView params={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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import * as z from 'zod';
|
|||||||
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
|||||||
const FORM_ID = 'resend-email';
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
export type ResendDocumentActionItemProps = {
|
export type ResendDocumentActionItemProps = {
|
||||||
document: Document;
|
document: Document & {
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
|
};
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ZResendDocumentFormSchema = z.object({
|
export const ZResendDocumentFormSchema = z.object({
|
||||||
@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
|
|||||||
export const ResendDocumentActionItem = ({
|
export const ResendDocumentActionItem = ({
|
||||||
document,
|
document,
|
||||||
recipients,
|
recipients,
|
||||||
|
team,
|
||||||
}: ResendDocumentActionItemProps) => {
|
}: ResendDocumentActionItemProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isOwner = document.userId === session?.user?.id;
|
const isOwner = document.userId === session?.user?.id;
|
||||||
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
!isOwner ||
|
(!isOwner && !isCurrentTeamDocument) ||
|
||||||
document.status !== 'PENDING' ||
|
document.status !== 'PENDING' ||
|
||||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||||
|
|
||||||
@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({
|
|||||||
|
|
||||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await resendDocument({ documentId: document.id, recipients });
|
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document re-sent',
|
title: 'Document re-sent',
|
||||||
|
|||||||
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Download, Edit, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
|
|||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -37,6 +40,10 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
const role = recipient?.role;
|
||||||
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -45,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
document = await trpcClient.document.getDocumentById.query({
|
document = await trpcClient.document.getDocumentById.query({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
document = await trpcClient.document.getDocumentByToken.query({
|
document = await trpcClient.document.getDocumentByToken.query({
|
||||||
@ -68,6 +76,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||||
|
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
@ -75,27 +88,48 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
isCurrentTeamDocument,
|
||||||
})
|
})
|
||||||
.with({ isOwner: true, isDraft: true }, () => (
|
.with(
|
||||||
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
|
() => (
|
||||||
<Button className="w-32" asChild>
|
<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 className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
),
|
||||||
|
)
|
||||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
{match(role)
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<>
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<>
|
||||||
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isPending: true, isSigned: true }, () => (
|
.with({ isPending: true, isSigned: true }, () => (
|
||||||
<Button className="w-32" disabled={true}>
|
<Button className="w-32" disabled={true}>
|
||||||
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
View
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
|
|||||||
@ -5,9 +5,11 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CheckCircle,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
|
EyeIcon,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@ -18,8 +20,9 @@ import {
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@ -40,10 +43,12 @@ export type DataTableActionDropdownProps = {
|
|||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -63,6 +68,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isDocumentDeletable = isOwner;
|
const isDocumentDeletable = isOwner;
|
||||||
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -71,6 +79,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
document = await trpcClient.document.getDocumentById.query({
|
document = await trpcClient.document.getDocumentById.query({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
document = await trpcClient.document.getDocumentByToken.query({
|
document = await trpcClient.document.getDocumentByToken.query({
|
||||||
@ -105,15 +114,35 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{recipient?.role !== RecipientRole.CC && (
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
|
<>
|
||||||
|
<EyeIcon className="mr-2 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipient?.role === RecipientRole.SIGNER && (
|
||||||
|
<>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipient?.role === RecipientRole.APPROVER && (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||||
<Link href={`/documents/${row.id}`}>
|
<Link href={`${documentsPath}/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
@ -141,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||||
|
|
||||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||||
|
|
||||||
<DocumentShareButton
|
<DocumentShareButton
|
||||||
documentId={row.id}
|
documentId={row.id}
|
||||||
@ -171,6 +200,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
|
team={team}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu>
|
</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 { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
|
||||||
|
type DataTableSenderFilterProps = {
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const senderIds = parseToIntegerArray(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 (
|
||||||
|
<MultiSelectCombobox
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
|
|||||||
Document & {
|
Document & {
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
showSenderColumn?: boolean;
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
export const DocumentsDataTable = ({
|
||||||
|
results,
|
||||||
|
showSenderColumn,
|
||||||
|
team,
|
||||||
|
}: DocumentsDataTableProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'sender',
|
||||||
|
header: 'Sender',
|
||||||
|
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
accessorKey: 'recipient',
|
accessorKey: 'recipient',
|
||||||
@ -79,8 +91,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
(!row.original.deletedAt ||
|
(!row.original.deletedAt ||
|
||||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DataTableActionButton row={row.original} />
|
<DataTableActionButton team={team} row={row.original} />
|
||||||
<DataTableActionDropdown row={row.original} />
|
<DataTableActionDropdown team={team} row={row.original} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
|
columnVisibility={{
|
||||||
|
sender: Boolean(showSenderColumn),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|||||||
155
apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
Normal file
155
apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
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 { PeriodSelectorValue } 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 { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
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 { 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 DocumentsPageViewProps = {
|
||||||
|
searchParams?: {
|
||||||
|
status?: ExtendedDocumentStatus;
|
||||||
|
period?: PeriodSelectorValue;
|
||||||
|
page?: string;
|
||||||
|
perPage?: string;
|
||||||
|
senderIds?: string;
|
||||||
|
};
|
||||||
|
team?: Team & { teamEmail?: TeamEmail | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
|
||||||
|
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 senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
|
||||||
|
const currentTeam = team ? { id: team.id, url: team.url } : undefined;
|
||||||
|
|
||||||
|
const getStatOptions: GetStatsInput = {
|
||||||
|
user,
|
||||||
|
period,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (team) {
|
||||||
|
getStatOptions.team = {
|
||||||
|
teamId: team.id,
|
||||||
|
teamEmail: team.teamEmail?.email,
|
||||||
|
senderIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await getStats(getStatOptions);
|
||||||
|
|
||||||
|
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 `${formatDocumentsPath(team?.url)}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<UploadDocument team={currentTeam} />
|
||||||
|
|
||||||
|
<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 value={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>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
team={currentTeam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
|
|||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicateDocumentDialog = ({
|
export const DuplicateDocumentDialog = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
team,
|
||||||
}: DuplicateDocumentDialogProps) => {
|
}: DuplicateDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||||
id,
|
id,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentData = document?.documentData
|
const documentData = document?.documentData
|
||||||
@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`/documents/${newId}`);
|
router.push(`${documentsPath}/${newId}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
|
|
||||||
const onDuplicate = async () => {
|
const onDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
await duplicateDocument({ id });
|
await duplicateDocument({ id, teamId: team?.id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
|
|||||||
@ -1,118 +1,16 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { DocumentsPageView } from './documents-page-view';
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
|
||||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
|
||||||
|
|
||||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
|
||||||
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
|
||||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
|
||||||
import { EmptyDocumentState } from './empty-state';
|
|
||||||
import { UploadDocument } from './upload-document';
|
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: {
|
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||||
status?: ExtendedDocumentStatus;
|
|
||||||
period?: PeriodSelectorValue;
|
|
||||||
page?: string;
|
|
||||||
perPage?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Documents',
|
title: 'Documents',
|
||||||
};
|
};
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const stats = await getStats({
|
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
user,
|
return <DocumentsPageView searchParams={searchParams} />;
|
||||||
});
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,8 +10,10 @@ import { useSession } from 'next-auth/react';
|
|||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -20,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
team?: {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
@ -38,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const disabledMessage = useMemo(() => {
|
const disabledMessage = useMemo(() => {
|
||||||
if (remaining.documents === 0) {
|
if (remaining.documents === 0) {
|
||||||
return 'You have reached your document limit.';
|
return team
|
||||||
|
? 'Document upload disabled due to unpaid invoices'
|
||||||
|
: 'You have reached your document limit.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user.emailVerified) {
|
if (!session?.user.emailVerified) {
|
||||||
return 'Verify your email to upload documents.';
|
return 'Verify your email to upload documents.';
|
||||||
}
|
}
|
||||||
}, [remaining.documents, session?.user.emailVerified]);
|
}, [remaining.documents, session?.user.emailVerified, team]);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
@ -60,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
const { id } = await createDocument({
|
const { id } = await createDocument({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -74,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`/documents/${id}`);
|
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
@ -96,6 +105,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFileDropRejected = () => {
|
||||||
|
toast({
|
||||||
|
title: 'Your document failed to upload.',
|
||||||
|
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
@ -103,10 +121,13 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
|
onDropRejected={onFileDropRejected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute -bottom-6 right-0">
|
<div className="absolute -bottom-6 right-0">
|
||||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
{team?.id === undefined &&
|
||||||
|
remaining.documents > 0 &&
|
||||||
|
Number.isFinite(remaining.documents) && (
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
<p className="text-muted-foreground/60 text-xs">
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
</p>
|
</p>
|
||||||
@ -119,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
</div>
|
</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="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const [{ user }, teams] = await Promise.all([
|
||||||
|
getRequiredServerComponentSession(),
|
||||||
|
getTeams({ userId: session.user.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
<Header user={user} />
|
|
||||||
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { createBillingPortal } from './create-billing-portal.action';
|
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 { toast } = useToast();
|
||||||
|
|
||||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||||
@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
<Button
|
||||||
|
{...buttonProps}
|
||||||
|
onClick={async () => handleFetchPortalUrl()}
|
||||||
|
loading={isFetchingPortalUrl}
|
||||||
|
>
|
||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
@ -36,23 +37,23 @@ export default async function BillingSettingsPage() {
|
|||||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [subscriptions, prices, individualPrices] = await Promise.all([
|
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
|
||||||
getSubscriptionsByUserId({ userId: user.id }),
|
getSubscriptionsByUserId({ userId: user.id }),
|
||||||
getPricesByInterval({ type: 'individual' }),
|
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
|
||||||
getPricesByType('individual'),
|
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const individualPriceIds = individualPrices.map(({ id }) => id);
|
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
|
||||||
|
|
||||||
let subscriptionProduct: Stripe.Product | null = null;
|
let subscriptionProduct: Stripe.Product | null = null;
|
||||||
|
|
||||||
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||||
individualPriceIds.includes(priceId),
|
communityPlanPriceIds.includes(priceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const subscription =
|
const subscription =
|
||||||
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||||
individualUserSubscriptions[0];
|
communityPlanUserSubscriptions[0];
|
||||||
|
|
||||||
if (subscription?.priceId) {
|
if (subscription?.priceId) {
|
||||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { ProfileForm } from '~/components/forms/profile';
|
import { ProfileForm } from '~/components/forms/profile';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -13,11 +14,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<ProfileForm user={user} className="max-w-xl" />
|
<ProfileForm user={user} className="max-w-xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Security activity',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsSecurityActivityPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">Security activity</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
View all recent security activity related to your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<UserSecurityActivityDataTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const dateFormat: DateTimeFormatOptions = {
|
||||||
|
...DateTime.DATETIME_SHORT,
|
||||||
|
hourCycle: 'h12',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserSecurityActivityDataTable = () => {
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||||
|
trpc.profile.findUserSecurityAuditLogs.useQuery(
|
||||||
|
{
|
||||||
|
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: 'Date',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Device',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.userAgent) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(row.original.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
let output = result.os.name;
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.os.version) {
|
||||||
|
output += ` (${result.os.version})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Browser',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.userAgent) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(row.original.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
return result.browser.name ?? 'N/A';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'IP Address',
|
||||||
|
accessorKey: 'ipAddress',
|
||||||
|
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||||
|
onClearFilters={() => router.push(pathname ?? '/')}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,8 +1,12 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
@ -16,53 +20,81 @@ export default async function SecuritySettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">Security</h3>
|
<SettingsHeader
|
||||||
|
title="Security"
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
subtitle="Here you can manage your password and security settings."
|
||||||
Here you can manage your password and security settings.
|
/>
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
{user.identityProvider === 'DOCUMENSO' ? (
|
{user.identityProvider === 'DOCUMENSO' ? (
|
||||||
<div>
|
<div>
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
<PasswordForm user={user} />
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
<hr className="border-border/50 mt-6" />
|
||||||
|
|
||||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Two factor authentication</AlertTitle>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<AlertDescription className="mr-4">
|
||||||
Add and manage your two factor security settings to add an extra layer of security to
|
Create one-time passwords that serve as a secondary authentication method for
|
||||||
your account!
|
confirming your identity when requested during the sign-in process.
|
||||||
</p>
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
<div className="mt-4 max-w-xl">
|
|
||||||
<h5 className="font-medium">Two-factor methods</h5>
|
|
||||||
|
|
||||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{user.twoFactorEnabled && (
|
{user.twoFactorEnabled && (
|
||||||
<div className="mt-4 max-w-xl">
|
<Alert
|
||||||
<h5 className="font-medium">Recovery methods</h5>
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Recovery codes</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-4">
|
||||||
|
Two factor authentication recovery codes are used to access your account in the
|
||||||
|
event that you lose access to your authenticator app.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<Alert className="p-6" variant="neutral">
|
||||||
<h4 className="text-lg font-medium">
|
<AlertTitle>
|
||||||
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
||||||
</h4>
|
</AlertTitle>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
|
||||||
|
<AlertDescription>
|
||||||
To update your password, enable two-factor authentication, and manage other security
|
To update your password, enable two-factor authentication, and manage other security
|
||||||
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
||||||
settings.
|
settings.
|
||||||
</p>
|
</AlertDescription>
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 mr-4 sm:mb-0">
|
||||||
|
<AlertTitle>Recent activity</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
View all recent security activity related to your account.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/settings/security/activity">View activity</Link>
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
39
apps/web/src/app/(dashboard)/settings/teams/page.tsx
Normal file
39
apps/web/src/app/(dashboard)/settings/teams/page.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { CreateTeamDialog } from '~/components/(teams)/dialogs/create-team-dialog';
|
||||||
|
import { UserSettingsTeamsPageDataTable } from '~/components/(teams)/tables/user-settings-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>
|
||||||
|
|
||||||
|
<UserSettingsTeamsPageDataTable />
|
||||||
|
|
||||||
|
<div className="mt-8 space-y-8">
|
||||||
|
<AnimatePresence>
|
||||||
|
{teamEmail && (
|
||||||
|
<AnimateGenericFadeInOut>
|
||||||
|
<TeamEmailUsage teamEmail={teamEmail} />
|
||||||
|
</AnimateGenericFadeInOut>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<TeamInvitations />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
Normal file
105
apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { TeamEmail } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type TeamEmailUsageProps = {
|
||||||
|
teamEmail: TeamEmail & { team: { name: string; url: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const 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 (
|
||||||
|
<Alert variant="neutral" className="flex flex-row items-center justify-between p-6">
|
||||||
|
<div>
|
||||||
|
<AlertTitle className="mb-0">Team Email</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
Your email is currently being used by team{' '}
|
||||||
|
<span className="font-semibold">{teamEmail.team.name}</span> ({teamEmail.team.url}
|
||||||
|
).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-1">They have permission on your behalf to:</p>
|
||||||
|
|
||||||
|
<ul className="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>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamEmail && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive">Revoke access</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
import { BellIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { formatTeamUrl } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import {
|
||||||
|
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 && (
|
||||||
|
<AnimateGenericFadeInOut>
|
||||||
|
<Alert variant="secondary">
|
||||||
|
<div className="flex h-full flex-row items-center p-2">
|
||||||
|
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
You have <strong>{data.length}</strong> pending team invitation
|
||||||
|
{data.length > 1 ? 's' : ''}.
|
||||||
|
</AlertDescription>
|
||||||
|
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
|
||||||
|
View invites
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
</AnimateGenericFadeInOut>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -28,6 +28,7 @@ export type EditTemplateFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
|
templateRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditTemplateStep = 'signers' | 'fields';
|
type EditTemplateStep = 'signers' | 'fields';
|
||||||
@ -40,6 +41,7 @@ export const EditTemplateForm = ({
|
|||||||
fields,
|
fields,
|
||||||
user: _user,
|
user: _user,
|
||||||
documentData,
|
documentData,
|
||||||
|
templateRootPath,
|
||||||
}: EditTemplateFormProps) => {
|
}: EditTemplateFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -98,7 +100,7 @@ export const EditTemplateForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/templates');
|
router.push(templateRootPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
|||||||
@ -1,81 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import type { TemplatePageViewProps } from './template-page-view';
|
||||||
import { redirect } from 'next/navigation';
|
import { TemplatePageView } from './template-page-view';
|
||||||
|
|
||||||
import { ChevronLeft } from 'lucide-react';
|
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
export default function TemplatePage({ params }: TemplatePageProps) {
|
||||||
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
return <TemplatePageView params={params} />;
|
||||||
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
|
||||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
|
||||||
|
|
||||||
import { TemplateType } from '~/components/formatter/template-type';
|
|
||||||
|
|
||||||
import { EditTemplateForm } from './edit-template';
|
|
||||||
|
|
||||||
export type TemplatePageProps = {
|
|
||||||
params: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const templateId = Number(id);
|
|
||||||
|
|
||||||
if (!templateId || Number.isNaN(templateId)) {
|
|
||||||
redirect('/documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const template = await getTemplateById({
|
|
||||||
id: templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (!template || !template.templateDocumentData) {
|
|
||||||
redirect('/documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { templateDocumentData } = template;
|
|
||||||
|
|
||||||
const [templateRecipients, templateFields] = await Promise.all([
|
|
||||||
getRecipientsForTemplate({
|
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
getFieldsForTemplate({
|
|
||||||
templateId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
Templates
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
|
||||||
{template.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EditTemplateForm
|
|
||||||
className="mt-8"
|
|
||||||
template={template}
|
|
||||||
user={user}
|
|
||||||
recipients={templateRecipients}
|
|
||||||
fields={templateFields}
|
|
||||||
documentData={templateDocumentData}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
|
||||||
|
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
|
||||||
|
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||||
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { TemplateType } from '~/components/formatter/template-type';
|
||||||
|
|
||||||
|
import { EditTemplateForm } from './edit-template';
|
||||||
|
|
||||||
|
export type TemplatePageViewProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatePageView = async ({ params, team }: TemplatePageViewProps) => {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
const templateId = Number(id);
|
||||||
|
const templateRootPath = formatTemplatesPath(team?.url);
|
||||||
|
|
||||||
|
if (!templateId || Number.isNaN(templateId)) {
|
||||||
|
redirect(templateRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const template = await getTemplateById({
|
||||||
|
id: templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!template || !template.templateDocumentData) {
|
||||||
|
redirect(templateRootPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { templateDocumentData } = template;
|
||||||
|
|
||||||
|
const [templateRecipients, templateFields] = await Promise.all([
|
||||||
|
getRecipientsForTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
getFieldsForTemplate({
|
||||||
|
templateId,
|
||||||
|
userId: user.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
|
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||||
|
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||||
|
Templates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||||
|
{template.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditTemplateForm
|
||||||
|
className="mt-8"
|
||||||
|
template={template}
|
||||||
|
user={user}
|
||||||
|
recipients={templateRecipients}
|
||||||
|
fields={templateFields}
|
||||||
|
documentData={templateDocumentData}
|
||||||
|
templateRootPath={templateRootPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -21,9 +21,15 @@ import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
|||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: Template;
|
row: Template;
|
||||||
|
templateRootPath: string;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({
|
||||||
|
row,
|
||||||
|
templateRootPath,
|
||||||
|
teamId,
|
||||||
|
}: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@ -34,6 +40,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = row.userId === session.user.id;
|
const isOwner = row.userId === session.user.id;
|
||||||
|
const isTeamTemplate = row.teamId === teamId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@ -44,20 +51,25 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner} asChild>
|
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||||
<Link href={`/templates/${row.id}`}>
|
<Link href={`${templateRootPath}/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
|
disabled={!isOwner && !isTeamTemplate}
|
||||||
|
onClick={() => setDuplicateDialogOpen(true)}
|
||||||
|
>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
|
<DropdownMenuItem
|
||||||
|
disabled={!isOwner && !isTeamTemplate}
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -65,6 +77,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
|
|
||||||
<DuplicateTemplateDialog
|
<DuplicateTemplateDialog
|
||||||
id={row.id}
|
id={row.id}
|
||||||
|
teamId={teamId}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader, Plus } from 'lucide-react';
|
import { AlertTriangle, Loader, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Template } from '@documenso/prisma/client';
|
import type { Template } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@ -25,6 +28,9 @@ type TemplatesDataTableProps = {
|
|||||||
perPage: number;
|
perPage: number;
|
||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
documentRootPath: string;
|
||||||
|
templateRootPath: string;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplatesDataTable = ({
|
export const TemplatesDataTable = ({
|
||||||
@ -32,10 +38,15 @@ export const TemplatesDataTable = ({
|
|||||||
perPage,
|
perPage,
|
||||||
page,
|
page,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
documentRootPath,
|
||||||
|
templateRootPath,
|
||||||
|
teamId,
|
||||||
}: TemplatesDataTableProps) => {
|
}: TemplatesDataTableProps) => {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const { remaining } = useLimits();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -65,7 +76,7 @@ export const TemplatesDataTable = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`/documents/${id}`);
|
router.push(`${documentRootPath}/${id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
@ -77,6 +88,19 @@ export const TemplatesDataTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{remaining.documents === 0 && (
|
||||||
|
<Alert variant="warning" className="mb-4">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Document Limit Exceeded!</AlertTitle>
|
||||||
|
<AlertDescription className="mt-2">
|
||||||
|
You have reached your document limit.{' '}
|
||||||
|
<Link className="underline underline-offset-4" href="/settings/billing">
|
||||||
|
Upgrade your account to continue!
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
@ -102,7 +126,7 @@ export const TemplatesDataTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<Button
|
<Button
|
||||||
disabled={isRowLoading}
|
disabled={isRowLoading || remaining.documents === 0}
|
||||||
loading={isRowLoading}
|
loading={isRowLoading}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
||||||
@ -113,7 +137,12 @@ export const TemplatesDataTable = ({
|
|||||||
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
|
||||||
Use Template
|
Use Template
|
||||||
</Button>
|
</Button>
|
||||||
<DataTableActionDropdown row={row.original} />
|
|
||||||
|
<DataTableActionDropdown
|
||||||
|
row={row.original}
|
||||||
|
teamId={teamId}
|
||||||
|
templateRootPath={templateRootPath}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -35,20 +35,15 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
|||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
onError: () => {
|
||||||
|
|
||||||
const onDeleteTemplate = async () => {
|
|
||||||
try {
|
|
||||||
await deleteTemplate({ id });
|
|
||||||
} catch {
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
description: 'This template could not be deleted at this time. Please try again.',
|
description: 'This template could not be deleted at this time. Please try again.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: 7500,
|
duration: 7500,
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
@ -63,20 +58,18 @@ export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateD
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
disabled={isLoading}
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="flex-1"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
|
<Button type="button" loading={isLoading} onClick={async () => deleteTemplate({ id })}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -14,12 +14,14 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
type DuplicateTemplateDialogProps = {
|
type DuplicateTemplateDialogProps = {
|
||||||
id: number;
|
id: number;
|
||||||
|
teamId?: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicateTemplateDialog = ({
|
export const DuplicateTemplateDialog = ({
|
||||||
id,
|
id,
|
||||||
|
teamId,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DuplicateTemplateDialogProps) => {
|
}: DuplicateTemplateDialogProps) => {
|
||||||
@ -40,21 +42,14 @@ export const DuplicateTemplateDialog = ({
|
|||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
onError: () => {
|
||||||
|
|
||||||
const onDuplicate = async () => {
|
|
||||||
try {
|
|
||||||
await duplicateTemplate({
|
|
||||||
templateId: id,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while duplicating template.',
|
description: 'An error occurred while duplicating template.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
@ -66,20 +61,27 @@ export const DuplicateTemplateDialog = ({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={isLoading}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="flex-1"
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={async () =>
|
||||||
|
duplicateTemplate({
|
||||||
|
templateId: id,
|
||||||
|
teamId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -43,8 +43,14 @@ const ZCreateTemplateFormSchema = z.object({
|
|||||||
|
|
||||||
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
|
||||||
|
|
||||||
export const NewTemplateDialog = () => {
|
type NewTemplateDialogProps = {
|
||||||
|
teamId?: number;
|
||||||
|
templateRootPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -99,6 +105,7 @@ export const NewTemplateDialog = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { id } = await createTemplate({
|
const { id } = await createTemplate({
|
||||||
|
teamId,
|
||||||
title: values.name ? values.name : file.name,
|
title: values.name ? values.name : file.name,
|
||||||
templateDocumentDataId,
|
templateDocumentDataId,
|
||||||
});
|
});
|
||||||
@ -112,7 +119,7 @@ export const NewTemplateDialog = () => {
|
|||||||
|
|
||||||
setShowNewTemplateDialog(false);
|
setShowNewTemplateDialog(false);
|
||||||
|
|
||||||
void router.push(`/templates/${id}`);
|
router.push(`${templateRootPath}/${id}`);
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
|
|||||||
@ -2,57 +2,17 @@ import React from 'react';
|
|||||||
|
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { TemplatesPageView } from './templates-page-view';
|
||||||
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
import type { TemplatesPageViewProps } from './templates-page-view';
|
||||||
|
|
||||||
import { TemplatesDataTable } from './data-table-templates';
|
|
||||||
import { EmptyTemplateState } from './empty-state';
|
|
||||||
import { NewTemplateDialog } from './new-template-dialog';
|
|
||||||
|
|
||||||
type TemplatesPageProps = {
|
type TemplatesPageProps = {
|
||||||
searchParams?: {
|
searchParams?: TemplatesPageViewProps['searchParams'];
|
||||||
page?: number;
|
|
||||||
perPage?: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Templates',
|
title: 'Templates',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
return <TemplatesPageView searchParams={searchParams} />;
|
||||||
const page = Number(searchParams.page) || 1;
|
|
||||||
const perPage = Number(searchParams.perPage) || 10;
|
|
||||||
|
|
||||||
const { templates, totalPages } = await getTemplates({
|
|
||||||
userId: user.id,
|
|
||||||
page: page,
|
|
||||||
perPage: perPage,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<NewTemplateDialog />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
{templates.length > 0 ? (
|
|
||||||
<TemplatesDataTable
|
|
||||||
templates={templates}
|
|
||||||
page={page}
|
|
||||||
perPage={perPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyTemplateState />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||||
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
|
|
||||||
|
import { TemplatesDataTable } from './data-table-templates';
|
||||||
|
import { EmptyTemplateState } from './empty-state';
|
||||||
|
import { NewTemplateDialog } from './new-template-dialog';
|
||||||
|
|
||||||
|
export type TemplatesPageViewProps = {
|
||||||
|
searchParams?: {
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplatesPageView = async ({ searchParams = {}, team }: TemplatesPageViewProps) => {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
const page = Number(searchParams.page) || 1;
|
||||||
|
const perPage = Number(searchParams.perPage) || 10;
|
||||||
|
|
||||||
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
const templateRootPath = formatTemplatesPath(team?.url);
|
||||||
|
|
||||||
|
const { templates, totalPages } = await findTemplates({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team?.id,
|
||||||
|
page: page,
|
||||||
|
perPage: perPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<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="truncate text-2xl font-semibold md:text-3xl">Templates</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<NewTemplateDialog templateRootPath={templateRootPath} teamId={team?.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-5">
|
||||||
|
{templates.length > 0 ? (
|
||||||
|
<TemplatesDataTable
|
||||||
|
templates={templates}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
documentRootPath={documentRootPath}
|
||||||
|
templateRootPath={templateRootPath}
|
||||||
|
teamId={team?.id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyTemplateState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
|||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
@ -94,7 +94,10 @@ export default async function CompletedSigningPage({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
You have signed
|
You have
|
||||||
|
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
||||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -96,15 +96,52 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
|
|
||||||
<fieldset
|
<fieldset
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
<div className={cn('flex flex-1 flex-col')}>
|
||||||
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'View Document'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{recipient.role === RecipientRole.VIEWER ? (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Please mark as viewed to complete
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="border-border mb-8 mt-4" />
|
||||||
|
|
||||||
|
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-y-4" />
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SignDialog
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
|
document={document}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
role={recipient.role}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Please review the document before signing.
|
Please review the document before signing.
|
||||||
</p>
|
</p>
|
||||||
@ -132,6 +169,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
@ -160,9 +198,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
document={document}
|
document={document}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
|
role={recipient.role}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
|
||||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
@ -12,10 +14,16 @@ export type SigningLayoutProps = {
|
|||||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||||
const { user, session } = await getServerComponentSession();
|
const { user, session } = await getServerComponentSession();
|
||||||
|
|
||||||
|
let teams: GetTeamsResponse = [];
|
||||||
|
|
||||||
|
if (user && session) {
|
||||||
|
teams = await getTeams({ userId: user.id });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{user && <AuthenticatedHeader user={user} />}
|
{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>
|
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
|||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-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 { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{document.User.name} ({document.User.email}) has invited you to sign this document.
|
{document.User.name} ({document.User.email}) has invited you to{' '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'view'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { Document, Field } from '@documenso/prisma/client';
|
import type { Document, Field } from '@documenso/prisma/client';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -17,6 +18,7 @@ export type SignDialogProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: () => void | Promise<void>;
|
||||||
|
role: RecipientRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignDialog = ({
|
export const SignDialog = ({
|
||||||
@ -25,6 +27,7 @@ export const SignDialog = ({
|
|||||||
fields,
|
fields,
|
||||||
fieldsValidated,
|
fieldsValidated,
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
|
role,
|
||||||
}: SignDialogProps) => {
|
}: SignDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
@ -45,9 +48,18 @@ export const SignDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-foreground text-xl font-semibold">Sign Document</div>
|
<div className="text-foreground text-xl font-semibold">
|
||||||
|
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
|
||||||
|
{role === RecipientRole.SIGNER && 'Sign Document'}
|
||||||
|
{role === RecipientRole.APPROVER && 'Approve Document'}
|
||||||
|
</div>
|
||||||
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||||
You are about to finish signing "{truncatedTitle}". Are you sure?
|
{role === RecipientRole.VIEWER &&
|
||||||
|
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
|
||||||
|
{role === RecipientRole.SIGNER &&
|
||||||
|
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
|
||||||
|
{role === RecipientRole.APPROVER &&
|
||||||
|
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -71,7 +83,9 @@ export const SignDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
onClick={onSignatureComplete}
|
||||||
>
|
>
|
||||||
Sign
|
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
||||||
|
{role === RecipientRole.SIGNER && 'Sign'}
|
||||||
|
{role === RecipientRole.APPROVER && 'Approve'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
20
apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
Normal file
20
apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
import { DocumentPageView } from '~/app/(dashboard)/documents/[id]/document-page-view';
|
||||||
|
|
||||||
|
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 <DocumentPageView params={params} team={team} />;
|
||||||
|
}
|
||||||
25
apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
Normal file
25
apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
import type { DocumentsPageViewProps } from '~/app/(dashboard)/documents/documents-page-view';
|
||||||
|
import { DocumentsPageView } from '~/app/(dashboard)/documents/documents-page-view';
|
||||||
|
|
||||||
|
export type TeamsDocumentPageProps = {
|
||||||
|
params: {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
searchParams?: DocumentsPageViewProps['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 <DocumentsPageView searchParams={searchParams} team={team} />;
|
||||||
|
}
|
||||||
54
apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
Normal file
54
apps/web/src/app/(teams)/t/[teamUrl]/error.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
Normal file
130
apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import type { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { type Subscription, SubscriptionStatus } 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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type LayoutBillingBannerProps = {
|
||||||
|
subscription: Subscription;
|
||||||
|
teamId: number;
|
||||||
|
userRole: TeamMemberRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LayoutBillingBanner = ({
|
||||||
|
subscription,
|
||||||
|
teamId,
|
||||||
|
userRole,
|
||||||
|
}: LayoutBillingBannerProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: createBillingPortal, isLoading } =
|
||||||
|
trpc.team.createBillingPortal.useMutation();
|
||||||
|
|
||||||
|
const handleCreatePortal = async () => {
|
||||||
|
try {
|
||||||
|
const sessionUrl = await createBillingPortal({ teamId });
|
||||||
|
|
||||||
|
window.open(sessionUrl, '_blank');
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subscription.status === SubscriptionStatus.ACTIVE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn({
|
||||||
|
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
|
||||||
|
subscription.status === SubscriptionStatus.PAST_DUE,
|
||||||
|
'bg-destructive text-destructive-foreground':
|
||||||
|
subscription.status === SubscriptionStatus.INACTIVE,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||||
|
|
||||||
|
{match(subscription.status)
|
||||||
|
.with(SubscriptionStatus.PAST_DUE, () => 'Payment overdue')
|
||||||
|
.with(SubscriptionStatus.INACTIVE, () => 'Teams restricted')
|
||||||
|
.exhaustive()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn({
|
||||||
|
'text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500':
|
||||||
|
subscription.status === SubscriptionStatus.PAST_DUE,
|
||||||
|
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
|
||||||
|
subscription.status === SubscriptionStatus.INACTIVE,
|
||||||
|
})}
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isOpen} onOpenChange={(value) => !isLoading && setIsOpen(value)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>Payment overdue</DialogTitle>
|
||||||
|
|
||||||
|
{match(subscription.status)
|
||||||
|
.with(SubscriptionStatus.PAST_DUE, () => (
|
||||||
|
<DialogDescription>
|
||||||
|
Your payment for teams is overdue. Please settle the payment to avoid any service
|
||||||
|
disruptions.
|
||||||
|
</DialogDescription>
|
||||||
|
))
|
||||||
|
.with(SubscriptionStatus.INACTIVE, () => (
|
||||||
|
<DialogDescription>
|
||||||
|
Due to an unpaid invoice, your team has been restricted. Please settle the payment
|
||||||
|
to restore full access to your team.
|
||||||
|
</DialogDescription>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
|
||||||
|
{canExecuteTeamAction('MANAGE_BILLING', userRole) && (
|
||||||
|
<DialogFooter>
|
||||||
|
<Button loading={isLoading} onClick={handleCreatePortal}>
|
||||||
|
Resolve payment
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
65
apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
Normal file
65
apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { RedirectType, redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
|
|
||||||
|
import { LayoutBillingBanner } from './layout-billing-banner';
|
||||||
|
|
||||||
|
export type AuthenticatedTeamsLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AuthenticatedTeamsLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: AuthenticatedTeamsLayoutProps) {
|
||||||
|
const { session, user } = await getServerComponentSession();
|
||||||
|
|
||||||
|
if (!session || !user) {
|
||||||
|
redirect('/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
|
||||||
|
getTeams({ userId: user.id }),
|
||||||
|
getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (getTeamPromise.status === 'rejected') {
|
||||||
|
redirect('/documents', RedirectType.replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = getTeamPromise.value;
|
||||||
|
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NextAuthProvider session={session}>
|
||||||
|
<LimitsProvider teamId={team.id}>
|
||||||
|
{team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||||
|
<LayoutBillingBanner
|
||||||
|
subscription={team.subscription}
|
||||||
|
teamId={team.id}
|
||||||
|
userRole={team.currentTeamMember.role}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
|
<RefreshOnFocus />
|
||||||
|
</LimitsProvider>
|
||||||
|
</NextAuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
Normal file
32
apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx
Normal file
@ -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,84 @@
|
|||||||
|
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-team';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
import { TeamBillingInvoicesDataTable } from '~/components/(teams)/tables/team-billing-invoices-data-table';
|
||||||
|
import { TeamBillingPortalButton } from '~/components/(teams)/team-billing-portal-button';
|
||||||
|
|
||||||
|
export type TeamsSettingsBillingPageProps = {
|
||||||
|
params: {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
|
||||||
|
const session = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const team = await getTeamByUrl({ userId: session.user.id, teamUrl: params.teamUrl });
|
||||||
|
|
||||||
|
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
|
||||||
|
|
||||||
|
let teamSubscription: Stripe.Subscription | null = null;
|
||||||
|
|
||||||
|
if (team.subscription) {
|
||||||
|
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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={
|
||||||
|
canManageBilling
|
||||||
|
? 'Manage team subscription.'
|
||||||
|
: 'You must be an admin of this team to manage billing.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TeamBillingPortalButton teamId={team.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<section className="mt-6">
|
||||||
|
<TeamBillingInvoicesDataTable teamId={team.id} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
Normal file
54
apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
|
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-team';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
|
import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
|
||||||
|
import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
|
||||||
|
|
||||||
|
export type TeamSettingsLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TeamsSettingsLayout({
|
||||||
|
children,
|
||||||
|
params: { teamUrl },
|
||||||
|
}: TeamSettingsLayoutProps) {
|
||||||
|
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,38 @@
|
|||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
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}
|
||||||
|
currentUserTeamRole={team.currentTeamMember.role}
|
||||||
|
/>
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<TeamsMemberPageDataTable
|
||||||
|
currentUserTeamRole={team.currentTeamMember.role}
|
||||||
|
teamId={team.id}
|
||||||
|
teamName={team.name}
|
||||||
|
teamOwnerUserId={team.ownerUserId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
186
apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
Normal file
186
apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { CheckCircle2, Clock } from 'lucide-react';
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
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
|
||||||
|
className="mb-4"
|
||||||
|
currentUserTeamRole={team.currentTeamMember.role}
|
||||||
|
teamId={team.id}
|
||||||
|
transferVerification={team.transferVerification}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
|
||||||
|
|
||||||
|
<section className="mt-6 space-y-6">
|
||||||
|
{(team.teamEmail || team.emailVerification) && (
|
||||||
|
<Alert className="p-6" variant="neutral">
|
||||||
|
<AlertTitle>Team email</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
You can view documents associated with this email and use this identity when sending
|
||||||
|
documents.
|
||||||
|
</AlertDescription>
|
||||||
|
|
||||||
|
<hr className="border-border/50 mt-2" />
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center justify-between pt-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">
|
||||||
|
{match({
|
||||||
|
teamEmail: team.teamEmail,
|
||||||
|
emailVerification: team.emailVerification,
|
||||||
|
})
|
||||||
|
.with({ teamEmail: P.not(null) }, () => (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
|
||||||
|
Active
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with(
|
||||||
|
{
|
||||||
|
emailVerification: P.when(
|
||||||
|
(emailVerification) =>
|
||||||
|
emailVerification && emailVerification?.expiresAt < new Date(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
|
||||||
|
Expired
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with({ emailVerification: P.not(null) }, () => (
|
||||||
|
<>
|
||||||
|
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
|
||||||
|
Awaiting email confirmation
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => null)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TeamEmailDropdown team={team} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!team.teamEmail && !team.emailVerification && (
|
||||||
|
<Alert
|
||||||
|
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Team email</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
|
||||||
|
{/* Feature not available yet. */}
|
||||||
|
{/* <li>Display this name and email when sending documents</li> */}
|
||||||
|
{/* <li>View documents associated with this email</li> */}
|
||||||
|
|
||||||
|
<span>View documents associated with this email</span>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddTeamEmailDialog teamId={team.id} />
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{team.ownerUserId === session.user.id && (
|
||||||
|
<>
|
||||||
|
{isTransferVerificationExpired && (
|
||||||
|
<Alert
|
||||||
|
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Transfer team</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
Transfer the ownership of the team to another team member.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TransferTeamDialog
|
||||||
|
ownerUserId={team.ownerUserId}
|
||||||
|
teamId={team.id}
|
||||||
|
teamName={team.name}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Delete team</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
This team, and any associated data excluding billing invoices will be permanently
|
||||||
|
deleted.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteTeamDialog teamId={team.id} teamName={team.name} />
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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-team';
|
||||||
|
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 const 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,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||||
|
import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type TeamTransferStatusProps = {
|
||||||
|
className?: string;
|
||||||
|
currentUserTeamRole: TeamMemberRole;
|
||||||
|
teamId: number;
|
||||||
|
transferVerification: TeamTransferVerification | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamTransferStatus = ({
|
||||||
|
className,
|
||||||
|
currentUserTeamRole,
|
||||||
|
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 && (
|
||||||
|
<AnimateGenericFadeInOut>
|
||||||
|
<Alert
|
||||||
|
variant={isExpired ? 'destructive' : 'warning'}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<AlertTitle>
|
||||||
|
{isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
{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} ({transferVerification.email})
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If they accept this request, the team will be transferred to their account.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && (
|
||||||
|
<Button
|
||||||
|
onClick={async () => deleteTeamTransferRequest({ teamId })}
|
||||||
|
loading={isLoading}
|
||||||
|
variant={isExpired ? 'destructive' : 'ghost'}
|
||||||
|
className={cn('ml-auto', {
|
||||||
|
'hover:bg-transparent hover:text-blue-800': !isExpired,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isExpired ? 'Close' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
</AnimateGenericFadeInOut>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
Normal file
22
apps/web/src/app/(teams)/t/[teamUrl]/templates/[id]/page.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
import type { TemplatePageViewProps } from '~/app/(dashboard)/templates/[id]/template-page-view';
|
||||||
|
import { TemplatePageView } from '~/app/(dashboard)/templates/[id]/template-page-view';
|
||||||
|
|
||||||
|
type TeamTemplatePageProps = {
|
||||||
|
params: TemplatePageViewProps['params'] & {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
|
||||||
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
return <TemplatePageView params={params} team={team} />;
|
||||||
|
}
|
||||||
26
apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
Normal file
26
apps/web/src/app/(teams)/t/[teamUrl]/templates/page.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
|
||||||
|
import type { TemplatesPageViewProps } from '~/app/(dashboard)/templates/templates-page-view';
|
||||||
|
import { TemplatesPageView } from '~/app/(dashboard)/templates/templates-page-view';
|
||||||
|
|
||||||
|
type TeamTemplatesPageProps = {
|
||||||
|
searchParams?: TemplatesPageViewProps['searchParams'];
|
||||||
|
params: {
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function TeamTemplatesPage({
|
||||||
|
searchParams = {},
|
||||||
|
params,
|
||||||
|
}: TeamTemplatesPageProps) {
|
||||||
|
const { teamUrl } = params;
|
||||||
|
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||||
|
|
||||||
|
return <TemplatesPageView searchParams={searchParams} team={team} />;
|
||||||
|
}
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { env } from 'next-runtime-env';
|
import { env } from 'next-runtime-env';
|
||||||
|
|
||||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
|
|
||||||
@ -11,9 +13,22 @@ export const metadata: Metadata = {
|
|||||||
title: 'Sign In',
|
title: 'Sign In',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SignInPage() {
|
type SignInPageProps = {
|
||||||
|
searchParams: {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
|
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||||
|
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||||
|
|
||||||
|
if (!email && rawEmail) {
|
||||||
|
redirect('/signin');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||||
@ -22,7 +37,11 @@ export default function SignInPage() {
|
|||||||
Welcome back, we are lucky to have you.
|
Welcome back, we are lucky to have you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
<SignInForm
|
||||||
|
className="mt-4"
|
||||||
|
initialEmail={email || undefined}
|
||||||
|
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||||
|
/>
|
||||||
|
|
||||||
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
|||||||
import { env } from 'next-runtime-env';
|
import { env } from 'next-runtime-env';
|
||||||
|
|
||||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
@ -12,13 +13,26 @@ export const metadata: Metadata = {
|
|||||||
title: 'Sign Up',
|
title: 'Sign Up',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SignUpPage() {
|
type SignUpPageProps = {
|
||||||
|
searchParams: {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||||
|
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||||
|
|
||||||
|
if (!email && rawEmail) {
|
||||||
|
redirect('/signup');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||||
@ -28,7 +42,11 @@ export default function SignUpPage() {
|
|||||||
signing is within your grasp.
|
signing is within your grasp.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
<SignUpForm
|
||||||
|
className="mt-4"
|
||||||
|
initialEmail={email || undefined}
|
||||||
|
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
|
|||||||
121
apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
Normal file
121
apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||||
|
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For users who do not exist yet, 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 email = encryptSecondaryData({
|
||||||
|
data: teamMemberInvite.email,
|
||||||
|
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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(email)}`}>Create account</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{isSessionUserTheInvitedUser ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Continue</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/signin?email=${encodeURIComponent(email)}`}>Continue to login</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||||
|
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 || isTokenExpired(teamEmailVerification.expiresAt)) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
let isTeamEmailVerificationError = false;
|
||||||
|
|
||||||
|
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 (e) {
|
||||||
|
console.error(e);
|
||||||
|
isTeamEmailVerificationError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTeamEmailVerificationError) {
|
||||||
|
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,80 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership';
|
||||||
|
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||||
|
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 || isTokenExpired(teamTransferVerification.expiresAt)) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
let isTransferError = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transferTeamOwnership({ token });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
isTransferError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransferError) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import React from 'react';
|
|||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -48,8 +49,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
<div
|
||||||
|
className="text-muted-foreground text-sm"
|
||||||
|
title="Click to copy signing link for sending to recipient"
|
||||||
|
>
|
||||||
|
<p>{recipient.email} </p>
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
<div className="">
|
||||||
|
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -197,20 +197,22 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
)}
|
)}
|
||||||
{!currentPage && (
|
{!currentPage && (
|
||||||
<>
|
<>
|
||||||
<CommandGroup heading="Documents">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Documents">
|
||||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Templates">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Templates">
|
||||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Settings">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Settings">
|
||||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Preferences">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Preferences">
|
||||||
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
|
||||||
|
Change theme
|
||||||
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<CommandGroup heading="Your documents">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Your documents">
|
||||||
<Commands push={push} pages={searchResults} />
|
<Commands push={push} pages={searchResults} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
@ -231,6 +233,7 @@ const Commands = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return pages.map((page, idx) => (
|
return pages.map((page, idx) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
className="-mx-2 -my-1 rounded-lg"
|
||||||
key={page.path + idx}
|
key={page.path + idx}
|
||||||
value={page.value ?? page.label}
|
value={page.value ?? page.label}
|
||||||
onSelect={() => push(page.path)}
|
onSelect={() => push(page.path)}
|
||||||
@ -255,7 +258,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
key={theme.theme}
|
key={theme.theme}
|
||||||
onSelect={() => setTheme(theme.theme)}
|
onSelect={() => setTheme(theme.theme)}
|
||||||
className="mx-2 first:mt-2 last:mb-2"
|
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
|
||||||
>
|
>
|
||||||
<theme.icon className="mr-2" />
|
<theme.icon className="mr-2" />
|
||||||
{theme.label}
|
{theme.label}
|
||||||
|
|||||||
@ -4,10 +4,11 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRootHref } from '@documenso/lib/utils/params';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -28,10 +29,13 @@ export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
|||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||||
|
|
||||||
|
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
||||||
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
||||||
@ -51,11 +55,13 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
{navigationLinks.map(({ href, label }) => (
|
{navigationLinks.map(({ href, label }) => (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={`${rootHref}${href}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
'text-muted-foreground dark:text-muted focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
|
||||||
{
|
{
|
||||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
|
'text-foreground dark:text-muted-foreground': pathname?.startsWith(
|
||||||
|
`${rootHref}${href}`,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,23 +1,40 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
import { getRootHref } from '@documenso/lib/utils/params';
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Logo } from '~/components/branding/logo';
|
import { Logo } from '~/components/branding/logo';
|
||||||
|
|
||||||
|
import { CommandMenu } from '../common/command-menu';
|
||||||
import { DesktopNav } from './desktop-nav';
|
import { DesktopNav } from './desktop-nav';
|
||||||
|
import { MenuSwitcher } from './menu-switcher';
|
||||||
|
import { MobileNavigation } from './mobile-navigation';
|
||||||
import { ProfileDropdown } from './profile-dropdown';
|
import { ProfileDropdown } from './profile-dropdown';
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
user: User;
|
user: User;
|
||||||
|
teams: GetTeamsResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const isTeamsEnabled = getFlag('app_teams');
|
||||||
|
|
||||||
|
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||||
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -30,6 +47,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
return () => window.removeEventListener('scroll', onScroll);
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!isTeamsEnabled) {
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -51,10 +69,50 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
|
|
||||||
<div className="flex gap-x-4 md:ml-8">
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
<ProfileDropdown user={user} />
|
<ProfileDropdown user={user} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
return (
|
||||||
<Menu className="h-6 w-6" />
|
<header
|
||||||
</Button> */}
|
className={cn(
|
||||||
|
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||||
|
scrollY > 5 && 'border-b-border',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<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={getRootHref(params)}
|
||||||
|
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||||
|
>
|
||||||
|
<Logo className="h-6 w-auto" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<DesktopNav />
|
||||||
|
|
||||||
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
|
<MenuSwitcher user={user} teams={teams} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||||
|
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||||
|
<SearchIcon className="text-muted-foreground h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={() => setIsHamburgerMenuOpen(true)}>
|
||||||
|
<MenuIcon className="text-muted-foreground h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
|
||||||
|
|
||||||
|
<MobileNavigation
|
||||||
|
isMenuOpen={isHamburgerMenuOpen}
|
||||||
|
onMenuOpenChange={setIsHamburgerMenuOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
230
apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
Normal file
230
apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { TEAM_MEMBER_ROLE_MAP, TEAM_URL_REGEX } from '@documenso/lib/constants/teams';
|
||||||
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
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,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
export type MenuSwitcherProps = {
|
||||||
|
user: User;
|
||||||
|
teams: GetTeamsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const isUserAdmin = isAdmin(user);
|
||||||
|
|
||||||
|
const { data: teamsQueryResult } = trpc.team.getTeams.useQuery(undefined, {
|
||||||
|
initialData: initialTeamsData,
|
||||||
|
});
|
||||||
|
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the redirect URL so we can switch between documents and templates page
|
||||||
|
* seemlessly between teams and personal accounts.
|
||||||
|
*/
|
||||||
|
const formatRedirectUrlOnSwitch = (teamUrl?: string) => {
|
||||||
|
const baseUrl = teamUrl ? `/t/${teamUrl}/` : '/';
|
||||||
|
|
||||||
|
const currentPathname = (pathname ?? '/').replace(TEAM_URL_REGEX, '');
|
||||||
|
|
||||||
|
if (currentPathname === '/templates') {
|
||||||
|
return `${baseUrl}templates`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
data-testid="menu-switcher"
|
||||||
|
variant="none"
|
||||||
|
className="relative flex h-12 flex-row items-center px-2 py-2 ring-0 focus-visible:border-0 focus-visible:ring-0"
|
||||||
|
>
|
||||||
|
<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={cn('z-[60] ml-2 w-full md:ml-0', teams ? 'min-w-[20rem]' : 'min-w-[12rem]')}
|
||||||
|
align="end"
|
||||||
|
forceMount
|
||||||
|
>
|
||||||
|
{teams ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>Personal</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={formatRedirectUrlOnSwitch()}>
|
||||||
|
<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 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="Create team"
|
||||||
|
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={formatRedirectUrlOnSwitch(team.url)}>
|
||||||
|
<AvatarWithText
|
||||||
|
avatarFallback={formatAvatarFallback(team.name)}
|
||||||
|
primaryText={team.name}
|
||||||
|
secondaryText={formatSecondaryAvatarText(team)}
|
||||||
|
rightSideComponent={
|
||||||
|
isPathTeamUrl(team.url) && (
|
||||||
|
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||||
|
<Link
|
||||||
|
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 />
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{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
|
||||||
|
className="text-destructive/90 hover:!text-destructive px-4 py-2"
|
||||||
|
onSelect={async () =>
|
||||||
|
signOut({
|
||||||
|
callbackUrl: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
import LogoImage from '@documenso/assets/logo.png';
|
||||||
|
import { getRootHref } from '@documenso/lib/utils/params';
|
||||||
|
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
|
||||||
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
|
|
||||||
|
export type MobileNavigationProps = {
|
||||||
|
isMenuOpen: boolean;
|
||||||
|
onMenuOpenChange?: (_value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigationProps) => {
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const handleMenuItemClick = () => {
|
||||||
|
onMenuOpenChange?.(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||||
|
|
||||||
|
const menuNavigationLinks = [
|
||||||
|
{
|
||||||
|
href: `${rootHref}/documents`,
|
||||||
|
text: 'Documents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${rootHref}/templates`,
|
||||||
|
text: 'Templates',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/settings/teams',
|
||||||
|
text: 'Teams',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/settings/profile',
|
||||||
|
text: 'Settings',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
|
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
||||||
|
<Link href="/" onClick={handleMenuItemClick}>
|
||||||
|
<Image
|
||||||
|
src={LogoImage}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="dark:invert"
|
||||||
|
width={170}
|
||||||
|
height={25}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-8 flex w-full flex-col items-start gap-y-4">
|
||||||
|
{menuNavigationLinks.map(({ href, text }) => (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||||
|
href={href}
|
||||||
|
onClick={() => handleMenuItemClick()}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-foreground hover:text-foreground/80 text-2xl font-semibold"
|
||||||
|
onClick={async () =>
|
||||||
|
signOut({
|
||||||
|
callbackUrl: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex w-full flex-col space-y-4 self-end">
|
||||||
|
<div className="w-fit">
|
||||||
|
<ThemeSwitcher />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -20,7 +20,7 @@ import { LuGithub } from 'react-icons/lu';
|
|||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -51,7 +51,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
|
||||||
const avatarFallback = user.name
|
const avatarFallback = user.name
|
||||||
? recipientInitials(user.name)
|
? extractInitials(user.name)
|
||||||
: user.email.slice(0, 1).toUpperCase();
|
: user.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -21,9 +21,9 @@ export const PeriodSelector = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const period = useMemo(() => {
|
const period = useMemo(() => {
|
||||||
const p = searchParams?.get('period') ?? '';
|
const p = searchParams?.get('period') ?? 'all';
|
||||||
|
|
||||||
return isPeriodSelectorValue(p) ? p : '';
|
return isPeriodSelectorValue(p) ? p : 'all';
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const onPeriodChange = (newPeriod: string) => {
|
const onPeriodChange = (newPeriod: string) => {
|
||||||
@ -35,7 +35,7 @@ export const PeriodSelector = () => {
|
|||||||
|
|
||||||
params.set('period', newPeriod);
|
params.set('period', newPeriod);
|
||||||
|
|
||||||
if (newPeriod === '') {
|
if (newPeriod === '' || newPeriod === 'all') {
|
||||||
params.delete('period');
|
params.delete('period');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ export const PeriodSelector = () => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
<SelectItem value="">All Time</SelectItem>
|
<SelectItem value="all">All Time</SelectItem>
|
||||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||||
<SelectItem value="14d">Last 14 days</SelectItem>
|
<SelectItem value="14d">Last 14 days</SelectItem>
|
||||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||||
|
|
||||||
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User } from 'lucide-react';
|
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -19,6 +19,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
const isTeamsEnabled = getFlag('app_teams');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
@ -35,6 +36,21 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{isTeamsEnabled && (
|
||||||
|
<Link href="/settings/teams">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-5 w-5" />
|
||||||
|
Teams
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type SettingsHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const 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 text-sm md:mt-2">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User } from 'lucide-react';
|
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -19,6 +19,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
const { getFlag } = useFeatureFlags();
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
const isBillingEnabled = getFlag('app_billing');
|
const isBillingEnabled = getFlag('app_billing');
|
||||||
|
const isTeamsEnabled = getFlag('app_teams');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -38,6 +39,21 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{isTeamsEnabled && (
|
||||||
|
<Link href="/settings/teams">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-5 w-5" />
|
||||||
|
Teams
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -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 { ZCreateTeamEmailVerificationMutationSchema } 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'>;
|
||||||
|
|
||||||
|
const ZCreateTeamEmailFormSchema = ZCreateTeamEmailVerificationMutationSchema.pick({
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateTeamEmailFormSchema = z.infer<typeof ZCreateTeamEmailFormSchema>;
|
||||||
|
|
||||||
|
export const AddTeamEmailDialog = ({ teamId, trigger, ...props }: AddTeamEmailDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TCreateTeamEmailFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreateTeamEmailFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createTeamEmailVerification, isLoading } =
|
||||||
|
trpc.team.createTeamEmailVerification.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createTeamEmailVerification({
|
||||||
|
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 position="center">
|
||||||
|
<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>
|
||||||
|
<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,178 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { Loader, TagIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type CreateTeamCheckoutDialogProps = {
|
||||||
|
pendingTeamId: number | null;
|
||||||
|
onClose: () => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const MotionCard = motion(Card);
|
||||||
|
|
||||||
|
export const CreateTeamCheckoutDialog = ({
|
||||||
|
pendingTeamId,
|
||||||
|
onClose,
|
||||||
|
...props
|
||||||
|
}: CreateTeamCheckoutDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.team.getTeamPrices.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync: createCheckout, isLoading: isCreatingCheckout } =
|
||||||
|
trpc.team.createTeamPendingCheckout.useMutation({
|
||||||
|
onSuccess: (checkoutUrl) => {
|
||||||
|
window.open(checkoutUrl, '_blank');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () =>
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We were unable to create a checkout session. Please try again, or contact support',
|
||||||
|
variant: 'destructive',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPrice = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data[interval];
|
||||||
|
}, [data, interval]);
|
||||||
|
|
||||||
|
const handleOnOpenChange = (open: boolean) => {
|
||||||
|
if (pendingTeamId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pendingTeamId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog {...props} open={pendingTeamId !== null} onOpenChange={handleOnOpenChange}>
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Team checkout</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Payment is required to finalise the creation of your team.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{(isLoading || !data) && (
|
||||||
|
<div className="flex h-20 items-center justify-center text-sm">
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader className="text-documenso h-6 w-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<p>Something went wrong</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && selectedPrice && !isLoading && (
|
||||||
|
<div>
|
||||||
|
<Tabs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
onValueChange={(value) => setInterval(value as 'monthly' | 'yearly')}
|
||||||
|
value={interval}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{[data.monthly, data.yearly].map((price) => (
|
||||||
|
<TabsTrigger key={price.priceId} className="w-full" value={price.interval}>
|
||||||
|
{price.friendlyInterval}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<MotionCard
|
||||||
|
key={selectedPrice.priceId}
|
||||||
|
initial={{ opacity: 0, y: 15 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0.15 } }}
|
||||||
|
>
|
||||||
|
<CardContent className="flex h-full flex-col p-6">
|
||||||
|
{selectedPrice.interval === 'monthly' ? (
|
||||||
|
<div className="text-muted-foreground text-lg font-medium">
|
||||||
|
$50 USD <span className="text-xs">per month</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-between text-lg font-medium">
|
||||||
|
<span>
|
||||||
|
$480 USD <span className="text-xs">per year</span>
|
||||||
|
</span>
|
||||||
|
<div className="bg-primary text-primary-foreground ml-2 inline-flex flex-row items-center justify-center rounded px-2 py-1 text-xs">
|
||||||
|
<TagIcon className="mr-1 h-4 w-4" />
|
||||||
|
20% off
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-muted-foreground mt-1.5 text-sm">
|
||||||
|
<p>This price includes minimum 5 seats.</p>
|
||||||
|
|
||||||
|
<p className="mt-1">
|
||||||
|
Adding and removing seats will adjust your invoice accordingly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</MotionCard>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isCreatingCheckout}
|
||||||
|
onClick={() => onClose()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={selectedPrice.interval === 'yearly'}
|
||||||
|
loading={isCreatingCheckout}
|
||||||
|
onClick={async () =>
|
||||||
|
createCheckout({
|
||||||
|
interval: selectedPrice.interval,
|
||||||
|
pendingTeamId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedPrice.interval === 'monthly' ? 'Checkout' : 'Coming soon'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
223
apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
Normal file
223
apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } 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 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'>;
|
||||||
|
|
||||||
|
const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({
|
||||||
|
teamName: true,
|
||||||
|
teamUrl: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateTeamFormSchema = z.infer<typeof ZCreateTeamFormSchema>;
|
||||||
|
|
||||||
|
export const CreateTeamDialog = ({ trigger, ...props }: CreateTeamDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const actionSearchParam = searchParams?.get('action');
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ZCreateTeamFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
teamName: '',
|
||||||
|
teamUrl: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createTeam } = trpc.team.createTeam.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ teamName, teamUrl }: TCreateTeamFormSchema) => {
|
||||||
|
try {
|
||||||
|
const response = await createTeam({
|
||||||
|
teamName,
|
||||||
|
teamUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
if (response.paymentRequired) {
|
||||||
|
router.push(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Your team has been created.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||||
|
form.setError('teamUrl', {
|
||||||
|
type: 'manual',
|
||||||
|
message: '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(() => {
|
||||||
|
form.reset();
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
Create team
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create team</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Create a team to collaborate with your team members.
|
||||||
|
</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 required>Team Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
onChange={(event) => {
|
||||||
|
const oldGeneratedUrl = mapTextToUrl(field.value);
|
||||||
|
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
||||||
|
|
||||||
|
const urlField = form.getValues('teamUrl');
|
||||||
|
if (urlField === oldGeneratedUrl) {
|
||||||
|
form.setValue('teamUrl', newGeneratedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
field.onChange(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="teamUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Team URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{!form.formState.errors.teamUrl && (
|
||||||
|
<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>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
data-testid="dialog-create-team-button"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Create Team
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
160
apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx
Normal file
160
apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx
Normal file
@ -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 const 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 position="center">
|
||||||
|
<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>
|
||||||
|
<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,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DeleteTeamMemberDialogProps = {
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
teamMemberId: number;
|
||||||
|
teamMemberName: string;
|
||||||
|
teamMemberEmail: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const 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 position="center">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Alert variant="neutral" padding="tight">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={teamMemberName.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<span className="font-semibold">{teamMemberName}</span>}
|
||||||
|
secondaryText={teamMemberEmail}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeletingTeamMember}>
|
||||||
|
<DialogFooter>
|
||||||
|
<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,244 @@
|
|||||||
|
'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_HIERARCHY, 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 type InviteTeamMembersDialogProps = {
|
||||||
|
currentUserTeamRole: TeamMemberRole;
|
||||||
|
teamId: number;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
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'] },
|
||||||
|
);
|
||||||
|
|
||||||
|
type TInviteTeamMembersFormSchema = z.infer<typeof ZInviteTeamMembersFormSchema>;
|
||||||
|
|
||||||
|
export const InviteTeamMembersDialog = ({
|
||||||
|
currentUserTeamRole,
|
||||||
|
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 position="center">
|
||||||
|
<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 space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].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"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => onAddTeamMemberInvite()}
|
||||||
|
>
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
Add more
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<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>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
'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 { Alert } from '@documenso/ui/primitives/alert';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type LeaveTeamDialogProps = {
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
role: TeamMemberRole;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const 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 position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are about to leave the following team.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral" padding="tight">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={teamName.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={teamName}
|
||||||
|
secondaryText={TEAM_MEMBER_ROLE_MAP[role]}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isLeavingTeam}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isLeavingTeam}
|
||||||
|
onClick={async () => leaveTeam({ teamId })}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
293
apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
Normal file
293
apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
'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 { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type TransferTeamDialogProps = {
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
ownerUserId: number;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const 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 ZTransferTeamFormSchema = z.object({
|
||||||
|
teamName: z.literal(confirmTransferMessage, {
|
||||||
|
errorMap: () => ({ message: `You must enter '${confirmTransferMessage}' to proceed` }),
|
||||||
|
}),
|
||||||
|
newOwnerUserId: z.string(),
|
||||||
|
clearPaymentMethods: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof ZTransferTeamFormSchema>>({
|
||||||
|
resolver: zodResolver(ZTransferTeamFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
teamName: '',
|
||||||
|
clearPaymentMethods: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async ({
|
||||||
|
newOwnerUserId,
|
||||||
|
clearPaymentMethods,
|
||||||
|
}: z.infer<typeof ZTransferTeamFormSchema>) => {
|
||||||
|
try {
|
||||||
|
await requestTeamOwnershipTransfer({
|
||||||
|
teamId,
|
||||||
|
newOwnerUserId: Number.parseInt(newOwnerUserId),
|
||||||
|
clearPaymentMethods,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 position="center">
|
||||||
|
<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} ({teamMember.user.email})
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Temporary removed. */}
|
||||||
|
{/* {IS_BILLING_ENABLED && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="clearPaymentMethods"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex flex-row items-center">
|
||||||
|
<Checkbox
|
||||||
|
id="clearPaymentMethods"
|
||||||
|
className="h-5 w-5 rounded-full"
|
||||||
|
checkClassName="dark:text-white text-primary"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="text-muted-foreground ml-2 text-sm"
|
||||||
|
htmlFor="clearPaymentMethods"
|
||||||
|
>
|
||||||
|
Clear current payment methods
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="list-outside list-disc space-y-2 pl-4">
|
||||||
|
{IS_BILLING_ENABLED && (
|
||||||
|
// Temporary removed.
|
||||||
|
// <li>
|
||||||
|
// {form.getValues('clearPaymentMethods')
|
||||||
|
// ? 'You will not be billed for any upcoming invoices'
|
||||||
|
// : 'We will continue to bill current payment methods if required'}
|
||||||
|
// </li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Any payment methods attached to this team will remain attached to this
|
||||||
|
team. Please contact us if you need to update this information.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li>
|
||||||
|
The selected team member will receive an email which they must accept before
|
||||||
|
the team is transferred
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" variant="destructive" loading={form.formState.isSubmitting}>
|
||||||
|
Request transfer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
) : (
|
||||||
|
<DialogContent
|
||||||
|
position="center"
|
||||||
|
className="text-muted-foreground flex items-center justify-center py-16 text-sm"
|
||||||
|
>
|
||||||
|
{loadingTeamMembers ? (
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-sm">
|
||||||
|
{loadingTeamMembersError
|
||||||
|
? '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 UpdateTeamEmailDialogProps = {
|
||||||
|
teamEmail: TeamEmail;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZUpdateTeamEmailFormSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||||
|
|
||||||
|
export const UpdateTeamEmailDialog = ({
|
||||||
|
teamEmail,
|
||||||
|
trigger,
|
||||||
|
...props
|
||||||
|
}: UpdateTeamEmailDialogProps) => {
|
||||||
|
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 position="center">
|
||||||
|
<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>
|
||||||
|
<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,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, 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_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/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 = {
|
||||||
|
currentUserTeamRole: TeamMemberRole;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
teamId: number;
|
||||||
|
teamMemberId: number;
|
||||||
|
teamMemberName: string;
|
||||||
|
teamMemberRole: TeamMemberRole;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZUpdateTeamMemberFormSchema = z.object({
|
||||||
|
role: z.nativeEnum(TeamMemberRole),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ZUpdateTeamMemberSchema = z.infer<typeof ZUpdateTeamMemberFormSchema>;
|
||||||
|
|
||||||
|
export const UpdateTeamMemberDialog = ({
|
||||||
|
currentUserTeamRole,
|
||||||
|
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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'You cannot modify a team member who has a higher role than you.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, currentUserTeamRole, teamMemberRole, form, toast]);
|
||||||
|
|
||||||
|
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 position="center">
|
||||||
|
<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">
|
||||||
|
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-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>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
apps/web/src/components/(teams)/forms/update-team-form.tsx
Normal file
173
apps/web/src/components/(teams)/forms/update-team-form.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
'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 type { 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 { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
|
||||||
|
|
||||||
|
export const 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,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { CreditCard, Settings, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
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 teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||||
|
|
||||||
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
|
<Link href={settingsPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname === settingsPath && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-5 w-5" />
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={membersPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-5 w-5" />
|
||||||
|
Members
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{IS_BILLING_ENABLED && (
|
||||||
|
<Link href={billingPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="mr-2 h-5 w-5" />
|
||||||
|
Billing
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||||
|
|
||||||
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Link href={settingsPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(settingsPath) &&
|
||||||
|
pathname.split('/').length === 4 &&
|
||||||
|
'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User className="mr-2 h-5 w-5" />
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={membersPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Key className="mr-2 h-5 w-5" />
|
||||||
|
Members
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{IS_BILLING_ENABLED && (
|
||||||
|
<Link href={billingPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(billingPath) && '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 { 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 } from '@documenso/lib/constants/teams';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
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 const CurrentUserTeamsDataTable = () => {
|
||||||
|
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,53 @@
|
|||||||
|
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 PendingUserTeamsDataTableActionsProps = {
|
||||||
|
className?: string;
|
||||||
|
pendingTeamId: number;
|
||||||
|
onPayClick: (pendingTeamId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PendingUserTeamsDataTableActions = ({
|
||||||
|
className,
|
||||||
|
pendingTeamId,
|
||||||
|
onPayClick,
|
||||||
|
}: PendingUserTeamsDataTableActionsProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTeamPending, isLoading: deletingTeam } =
|
||||||
|
trpc.team.deleteTeamPending.useMutation({
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset disabled={deletingTeam} className={cn('flex justify-end space-x-2', className)}>
|
||||||
|
<Button variant="outline" onClick={() => onPayClick(pendingTeamId)}>
|
||||||
|
Pay
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
loading={deletingTeam}
|
||||||
|
onClick={async () => deleteTeamPending({ pendingTeamId: pendingTeamId })}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user