mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Merge branch 'main' into feat/disable-access-unverified-users
This commit is contained in:
@ -5,9 +5,9 @@ NEXTAUTH_SECRET="secret"
|
||||
# [[CRYPTO]]
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# REQUIRED: This should be a random string of at least 32 characters
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY=""
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||
# REQUIRED: This should be a random string of at least 32 characters
|
||||
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY=""
|
||||
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||
|
||||
# [[AUTH OPTIONAL]]
|
||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||
@ -25,7 +25,7 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
|
||||
# [[STORAGE]]
|
||||
# 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=
|
||||
# OPTIONAL: The private key to use for DKIM signing.
|
||||
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]]
|
||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||
|
||||
87
apps/marketing/content/blog/commodifying-signing.mdx
Normal file
87
apps/marketing/content/blog/commodifying-signing.mdx
Normal file
@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Commodifying Signing
|
||||
description: We are creating signing as a public good and are commoditizing it to make it cheaper and better.
|
||||
authorName: 'Timur Ercan'
|
||||
authorImage: '/blog/blog-author-timur.jpeg'
|
||||
authorRole: 'Co-Founder'
|
||||
date: 2024-01-25
|
||||
Tags:
|
||||
- Vision
|
||||
- Mission
|
||||
- Open Source
|
||||
---
|
||||
|
||||
<figure>
|
||||
<MdxNextImage
|
||||
src="/blog/lighthouse.jpeg"
|
||||
width="650"
|
||||
height="650"
|
||||
alt="A lighthouse on a tiny island."
|
||||
/>
|
||||
|
||||
<figcaption className="text-center">
|
||||
Lighthouses are often used as an example of a public good; As they benefit all maritime users, but no one can be excluded from using them as a navigational aid. Use by one person neither prevents access by other people, nor does it reduce availability to others.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
# Commodifying Signing
|
||||
|
||||
> TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better.
|
||||
|
||||
While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means.
|
||||
|
||||
Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly.
|
||||
|
||||
## Is signing already a commodity?
|
||||
|
||||
> In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them.
|
||||
|
||||
That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market?
|
||||
|
||||
- Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume.
|
||||
- Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to
|
||||
|
||||
To understand why, we need to look at the landscape as it is today:
|
||||
|
||||
- **Commodity**: Signing SaaS
|
||||
- **Private Goods**: Signing Code Base, Regulatory Know-How
|
||||
- **Public Goods**: Web Tech, Digital Signature Algorithms and Standards
|
||||
|
||||
What the current players have done is to commodify the listed public goods into commercial products:
|
||||
|
||||
> […]the action and process of transforming goods, services, ideas, nature, personal information, people, or animals into commodities.
|
||||
> (Let's ignore the end of that list for now and what it says about humanity, yikes)
|
||||
|
||||
While this paradigm brought digital signing to many businesses worldwide, we aim for a different future. To solve signing once and for all, we need to achieve two core points:
|
||||
|
||||
- Making it cheaper so it's profitable for everyone to use
|
||||
- Making it more accessible so everyone can use it (e.g. regulated industries) and flexible enough (extendable, open).
|
||||
|
||||
To achieve this, we must transform the landscape to look like this:
|
||||
|
||||
- **Commodities**: Enterprise Components, Support, Hosting, Self-Host Licenses
|
||||
- **Public Goods**: (no longer private): OS (Open Source) Signing Code Base, OS Regulatory Know-How
|
||||
- **Public Goods**: OS Web Tech, Digital Signature Algorithms and Standards
|
||||
|
||||
## Raising the Bar
|
||||
|
||||
Before creating a commodity, we are raising the bar of what the underlying public good is. Having an open source singing framework you can extend, self-host, and understand makes the resulting solution much more accessible and extendable for everyone. Now for the final feat of making signing cheaper:
|
||||
|
||||
As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I:
|
||||
|
||||
> In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers.
|
||||
|
||||
By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish.
|
||||
|
||||
## Changing the Game
|
||||
|
||||
In this new world, a company needing signing (literally every company) can decide if the ROI (Return on Investment) of building signing themselves is greater than simply paying for the value-added activities they will need anyway. Pricing our offering not on volume but fixed is a nice additional wedge into the market we intend to use here.
|
||||
|
||||
The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital efficiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect.
|
||||
|
||||
We will grow our community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards.
|
||||
|
||||
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 or comments.
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog.
|
||||
While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead.
|
||||
We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live).
|
||||
|
||||
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
|
||||
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
|
||||
|
||||
Best from Hamburg\
|
||||
Timur
|
||||
|
||||
@ -7,6 +7,8 @@ authorRole: 'Co-Founder'
|
||||
date: 2023-07-13
|
||||
tags:
|
||||
- Manifesto
|
||||
- Open Source
|
||||
- Vision
|
||||
---
|
||||
|
||||
<figure>
|
||||
|
||||
BIN
apps/marketing/public/blog/lighthouse.jpeg
Normal file
BIN
apps/marketing/public/blog/lighthouse.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
@ -15,7 +15,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
|
||||
notFound();
|
||||
}
|
||||
|
||||
return { title: `Documenso - ${document.title}` };
|
||||
return { title: document.title };
|
||||
};
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
|
||||
@ -18,7 +18,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Documenso - ${blogPost.title}`,
|
||||
title: {
|
||||
absolute: `${blogPost.title} - Documenso Blog`,
|
||||
},
|
||||
description: blogPost.description,
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { allBlogPosts } from 'contentlayer/generated';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog',
|
||||
};
|
||||
export default function BlogPage() {
|
||||
const blogPosts = allBlogPosts.sort((a, b) => {
|
||||
const dateA = new Date(a.date);
|
||||
|
||||
@ -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" />
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
@ -14,6 +16,10 @@ import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
|
||||
import { TeamMembers } from './team-members';
|
||||
import { OpenPageTooltip } from './tooltip';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Open Startup',
|
||||
};
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
|
||||
import { z } from 'zod';
|
||||
@ -5,7 +6,12 @@ import { z } from 'zod';
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
|
||||
import { OSSFriendsContainer } from './container';
|
||||
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
|
||||
import type { TOSSFriendsSchema } from './schema';
|
||||
import { ZOSSFriendsSchema } from './schema';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OSS Friends',
|
||||
};
|
||||
|
||||
export default async function OSSFriendsPage() {
|
||||
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
|
||||
import type { Metadata } from 'next';
|
||||
import { Caveat } from 'next/font/google';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -10,6 +11,11 @@ import { OpenBuildTemplateBento } from '~/components/(marketing)/open-build-temp
|
||||
import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento';
|
||||
|
||||
export const revalidate = 600;
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: 'Documenso - The Open Source DocuSign Alternative',
|
||||
},
|
||||
};
|
||||
|
||||
const fontCaveat = Caveat({
|
||||
weight: ['500'],
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
'use client';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
@ -12,6 +11,10 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { PricingTable } from '~/components/(marketing)/pricing-table';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pricing',
|
||||
};
|
||||
|
||||
export type PricingPageProps = {
|
||||
searchParams?: {
|
||||
planId?: string;
|
||||
|
||||
@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
|
||||
readStatus: 'OPENED',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
role: 'SIGNER',
|
||||
};
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { SinglePlayerClient } from './client';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Singleplayer',
|
||||
};
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
// !: This entire file is a hack to get around failed prerendering of
|
||||
|
||||
@ -18,7 +18,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
title: {
|
||||
template: '%s - Documenso',
|
||||
default: 'Documenso',
|
||||
},
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
keywords:
|
||||
|
||||
@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
||||
</DialogDescription>
|
||||
|
||||
<SignaturePad
|
||||
disabled={isSubmitting}
|
||||
className="aspect-video w-full rounded-md border"
|
||||
defaultValue={signatureDataUrl || ''}
|
||||
onChange={setDraftSignatureDataUrl}
|
||||
|
||||
@ -45,6 +45,7 @@
|
||||
"sharp": "0.33.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"uqr": "^0.1.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
@ -53,7 +54,8 @@
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/node": "20.1.0",
|
||||
"@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": {
|
||||
"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 { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { Document, User } from '@documenso/prisma/client';
|
||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document, User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
accessorKey: 'owner',
|
||||
cell: ({ row }) => {
|
||||
const avatarFallbackText = row.original.User.name
|
||||
? recipientInitials(row.original.User.name)
|
||||
? extractInitials(row.original.User.name)
|
||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
|
||||
@ -19,7 +19,7 @@ type ComboboxProps = {
|
||||
onChange: (_values: string[]) => void;
|
||||
};
|
||||
|
||||
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||
const dbRoles = Object.values(Role);
|
||||
@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export { MultiSelectCombobox };
|
||||
export { MultiSelectRoleCombobox };
|
||||
@ -18,9 +18,10 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
|
||||
|
||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||
|
||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||
@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
<MultiSelectRoleCombobox
|
||||
listValues={roles}
|
||||
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 { search } from './fetch-users.actions';
|
||||
@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
||||
|
||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||
search(searchString, page, perPage),
|
||||
getPricesByType('individual'),
|
||||
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||
]);
|
||||
|
||||
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 default async function DocumentPageView({ 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;
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||
@ -45,6 +46,7 @@ export const EditDocumentForm = ({
|
||||
documentMeta,
|
||||
user: _user,
|
||||
documentData,
|
||||
documentRootPath,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
@ -168,7 +170,7 @@ export const EditDocumentForm = ({
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.push('/documents');
|
||||
router.push(documentRootPath);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@ -218,9 +220,9 @@ export const EditDocumentForm = ({
|
||||
<AddTitleFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.title}
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
document={document}
|
||||
onSubmit={onAddTitleFormSubmit}
|
||||
/>
|
||||
|
||||
|
||||
@ -1,20 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
|
||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
import DocumentPageView from './document-page-view';
|
||||
|
||||
export type DocumentPageProps = {
|
||||
params: {
|
||||
@ -22,103 +6,6 @@ export type DocumentPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
||||
const { id } = params;
|
||||
|
||||
const documentId = Number(id);
|
||||
|
||||
if (!documentId || Number.isNaN(documentId)) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const document = await getDocumentById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
redirect('/documents');
|
||||
}
|
||||
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
if (documentMeta?.password) {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const securePassword = Buffer.from(
|
||||
symmetricDecrypt({
|
||||
key,
|
||||
data: documentMeta.password,
|
||||
}),
|
||||
).toString('utf-8');
|
||||
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Documents
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="text-muted-foreground flex items-center">
|
||||
<Users2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
||||
<span>{recipients.length} Recipient(s)</span>
|
||||
</StackAvatarsWithTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
||||
<div className="mx-auto mt-12 max-w-2xl">
|
||||
<LazyPDFViewer
|
||||
document={document}
|
||||
key={documentData.id}
|
||||
documentMeta={documentMeta}
|
||||
documentData={documentData}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||
return <DocumentPageView params={params} />;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import * as z from 'zod';
|
||||
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
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 { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type ResendDocumentActionItemProps = {
|
||||
document: Document;
|
||||
document: Document & {
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: Recipient[];
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
|
||||
export const ResendDocumentActionItem = ({
|
||||
document,
|
||||
recipients,
|
||||
team,
|
||||
}: ResendDocumentActionItemProps) => {
|
||||
const { data: session } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isOwner = document.userId === session?.user?.id;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
|
||||
const isDisabled =
|
||||
!isOwner ||
|
||||
(!isOwner && !isCurrentTeamDocument) ||
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
||||
|
||||
toast({
|
||||
title: 'Document re-sent',
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
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 { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
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 { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
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 { toast } = useToast();
|
||||
|
||||
@ -37,6 +40,10 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
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 () => {
|
||||
try {
|
||||
@ -45,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
if (!recipient) {
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
} else {
|
||||
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({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
@ -75,27 +88,48 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
isPending,
|
||||
isComplete,
|
||||
isSigned,
|
||||
isCurrentTeamDocument,
|
||||
})
|
||||
.with({ isOwner: true, isDraft: true }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
))
|
||||
.with(
|
||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||
() => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
),
|
||||
)
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
Sign
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
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>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: true }, () => (
|
||||
<Button className="w-32" disabled={true}>
|
||||
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
Sign
|
||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
|
||||
@ -5,9 +5,11 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
EyeIcon,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@ -18,8 +20,9 @@ import {
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
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 { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
@ -40,10 +43,12 @@ export type DataTableActionDropdownProps = {
|
||||
row: Document & {
|
||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||
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 { toast } = useToast();
|
||||
|
||||
@ -63,6 +68,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isDocumentDeletable = isOwner;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
try {
|
||||
@ -71,6 +79,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
if (!recipient) {
|
||||
document = await trpcClient.document.getDocumentById.query({
|
||||
id: row.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
} else {
|
||||
document = await trpcClient.document.getDocumentByToken.query({
|
||||
@ -105,15 +114,35 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
{recipient?.role === RecipientRole.VIEWER && (
|
||||
<>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
{recipient?.role === RecipientRole.SIGNER && (
|
||||
<>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipient?.role === RecipientRole.APPROVER && (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||
<Link href={`${documentsPath}/${row.id}`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
@ -141,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
|
||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={row.id}
|
||||
@ -171,6 +200,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
||||
id={row.id}
|
||||
open={isDuplicateDialogOpen}
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
team={team}
|
||||
/>
|
||||
)}
|
||||
</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 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 { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
|
||||
Document & {
|
||||
Recipient: Recipient[];
|
||||
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 [isPending, startTransition] = useTransition();
|
||||
|
||||
@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
header: 'Title',
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
},
|
||||
{
|
||||
id: 'sender',
|
||||
header: 'Sender',
|
||||
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||
},
|
||||
{
|
||||
header: 'Recipient',
|
||||
accessorKey: 'recipient',
|
||||
@ -79,8 +91,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
(!row.original.deletedAt ||
|
||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<DataTableActionButton row={row.original} />
|
||||
<DataTableActionDropdown row={row.original} />
|
||||
<DataTableActionButton team={team} row={row.original} />
|
||||
<DataTableActionDropdown team={team} row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
columnVisibility={{
|
||||
sender: Boolean(showSenderColumn),
|
||||
}}
|
||||
>
|
||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||
</DataTable>
|
||||
|
||||
158
apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
Normal file
158
apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
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 default async function DocumentsPageView({
|
||||
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
team?: Pick<Team, 'id' | 'url'>;
|
||||
};
|
||||
|
||||
export const DuplicateDocumentDialog = ({
|
||||
id,
|
||||
open,
|
||||
onOpenChange,
|
||||
team,
|
||||
}: DuplicateDocumentDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||
id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const documentData = document?.documentData
|
||||
@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const documentsPath = formatDocumentsPath(team?.url);
|
||||
|
||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||
trpcReact.document.duplicateDocument.useMutation({
|
||||
onSuccess: (newId) => {
|
||||
router.push(`/documents/${newId}`);
|
||||
router.push(`${documentsPath}/${newId}`);
|
||||
|
||||
toast({
|
||||
title: 'Document Duplicated',
|
||||
@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
|
||||
|
||||
const onDuplicate = async () => {
|
||||
try {
|
||||
await duplicateDocument({ id });
|
||||
await duplicateDocument({ id, teamId: team?.id });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
|
||||
@ -1,114 +1,16 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
|
||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||
|
||||
import { DocumentsDataTable } from './data-table';
|
||||
import { EmptyDocumentState } from './empty-state';
|
||||
import { UploadDocument } from './upload-document';
|
||||
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||
import DocumentsPageView from './documents-page-view';
|
||||
|
||||
export type DocumentsPageProps = {
|
||||
searchParams?: {
|
||||
status?: ExtendedDocumentStatus;
|
||||
period?: PeriodSelectorValue;
|
||||
page?: string;
|
||||
perPage?: string;
|
||||
};
|
||||
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||
};
|
||||
|
||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
export const metadata: Metadata = {
|
||||
title: 'Documents',
|
||||
};
|
||||
|
||||
const stats = await getStats({
|
||||
user,
|
||||
});
|
||||
|
||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||
const page = Number(searchParams.page) || 1;
|
||||
const perPage = Number(searchParams.perPage) || 20;
|
||||
|
||||
const results = await findDocuments({
|
||||
userId: user.id,
|
||||
status,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: 'desc',
|
||||
},
|
||||
page,
|
||||
perPage,
|
||||
period,
|
||||
});
|
||||
|
||||
const getTabHref = (value: typeof status) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.set('status', value);
|
||||
|
||||
if (params.has('page')) {
|
||||
params.delete('page');
|
||||
}
|
||||
|
||||
return `/documents?${params.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||
<UploadDocument />
|
||||
|
||||
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
|
||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||
|
||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
||||
<TabsList>
|
||||
{[
|
||||
ExtendedDocumentStatus.INBOX,
|
||||
ExtendedDocumentStatus.PENDING,
|
||||
ExtendedDocumentStatus.COMPLETED,
|
||||
ExtendedDocumentStatus.DRAFT,
|
||||
ExtendedDocumentStatus.ALL,
|
||||
].map((value) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
className="hover:text-foreground min-w-[60px]"
|
||||
value={value}
|
||||
asChild
|
||||
>
|
||||
<Link href={getTabHref(value)} scroll={false}>
|
||||
<DocumentStatus status={value} />
|
||||
|
||||
{value !== ExtendedDocumentStatus.ALL && (
|
||||
<span className="ml-1 inline-block opacity-50">
|
||||
{Math.min(stats[value], 99)}
|
||||
{stats[value] > 99 && '+'}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
|
||||
<PeriodSelector />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{results.count > 0 && <DocumentsDataTable results={results} />}
|
||||
{results.count === 0 && <EmptyDocumentState status={status} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||
return <DocumentsPageView searchParams={searchParams} />;
|
||||
}
|
||||
|
||||
@ -10,8 +10,10 @@ import { useSession } from 'next-auth/react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
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 { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -20,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type UploadDocumentProps = {
|
||||
className?: string;
|
||||
team?: {
|
||||
id: number;
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||
const router = useRouter();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
@ -38,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
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) {
|
||||
return 'Verify your email to upload documents.';
|
||||
}
|
||||
}, [remaining.documents, session?.user.emailVerified]);
|
||||
}, [remaining.documents, session?.user.emailVerified, team]);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
@ -60,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
toast({
|
||||
@ -74,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
router.push(`/documents/${id}`);
|
||||
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
||||
} catch (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 (
|
||||
<div className={cn('relative', className)}>
|
||||
<DocumentDropzone
|
||||
@ -103,14 +121,17 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={onFileDrop}
|
||||
onDropRejected={onFileDropRejected}
|
||||
/>
|
||||
|
||||
<div className="absolute -bottom-6 right-0">
|
||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
{team?.id === undefined &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
@ -119,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{remaining.documents === 0 && (
|
||||
{team?.id === undefined && remaining.documents === 0 && (
|
||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||
<div className="text-center">
|
||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||
|
||||
@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const [{ user }, teams] = await Promise.all([
|
||||
getRequiredServerComponentSession(),
|
||||
getTeams({ userId: session.user.id }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
<Header user={user} />
|
||||
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
|
||||
@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { createBillingPortal } from './create-billing-portal.action';
|
||||
|
||||
export const BillingPortalButton = () => {
|
||||
export type BillingPortalButtonProps = {
|
||||
buttonProps?: React.ComponentProps<typeof Button>;
|
||||
};
|
||||
|
||||
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||
@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={async () => handleFetchPortalUrl()}
|
||||
loading={isFetchingPortalUrl}
|
||||
>
|
||||
Manage Subscription
|
||||
</Button>
|
||||
);
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
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 { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||
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 { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||
@ -17,6 +19,10 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
import { BillingPlans } from './billing-plans';
|
||||
import { BillingPortalButton } from './billing-portal-button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Billing',
|
||||
};
|
||||
|
||||
export default async function BillingSettingsPage() {
|
||||
let { user } = await getRequiredServerComponentSession();
|
||||
|
||||
@ -31,23 +37,23 @@ export default async function BillingSettingsPage() {
|
||||
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 }),
|
||||
getPricesByInterval({ type: 'individual' }),
|
||||
getPricesByType('individual'),
|
||||
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
|
||||
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||
]);
|
||||
|
||||
const individualPriceIds = individualPrices.map(({ id }) => id);
|
||||
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
|
||||
|
||||
let subscriptionProduct: Stripe.Product | null = null;
|
||||
|
||||
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
individualPriceIds.includes(priceId),
|
||||
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||
communityPlanPriceIds.includes(priceId),
|
||||
);
|
||||
|
||||
const subscription =
|
||||
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
individualUserSubscriptions[0];
|
||||
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||
communityPlanUserSubscriptions[0];
|
||||
|
||||
if (subscription?.priceId) {
|
||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
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';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Profile',
|
||||
};
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
||||
|
||||
<ProfileForm user={user} className="max-w-xl" />
|
||||
</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,62 +1,100 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||
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 { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||
import { PasswordForm } from '~/components/forms/password';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Security',
|
||||
};
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Security</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Here you can manage your password and security settings.
|
||||
</p>
|
||||
|
||||
<hr className="my-4" />
|
||||
<SettingsHeader
|
||||
title="Security"
|
||||
subtitle="Here you can manage your password and security settings."
|
||||
/>
|
||||
|
||||
{user.identityProvider === 'DOCUMENSO' ? (
|
||||
<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">
|
||||
Add and manage your two factor security settings to add an extra layer of security to
|
||||
your account!
|
||||
</p>
|
||||
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Two-factor methods</h5>
|
||||
<AlertDescription className="mr-4">
|
||||
Create one-time passwords that serve as a secondary authentication method for
|
||||
confirming your identity when requested during the sign-in process.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<div className="mt-4 max-w-xl">
|
||||
<h5 className="font-medium">Recovery methods</h5>
|
||||
<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>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} />
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h4 className="text-lg font-medium">
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>
|
||||
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
To update your password, enable two-factor authentication, and manage other security
|
||||
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
||||
settings.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -2,13 +2,16 @@
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
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 type { Template } 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 { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
@ -36,6 +39,8 @@ export const TemplatesDataTable = ({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const { remaining } = useLimits();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
@ -77,6 +82,19 @@ export const TemplatesDataTable = ({
|
||||
|
||||
return (
|
||||
<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
|
||||
columns={[
|
||||
{
|
||||
@ -102,7 +120,7 @@ export const TemplatesDataTable = ({
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Button
|
||||
disabled={isRowLoading}
|
||||
disabled={isRowLoading || remaining.documents === 0}
|
||||
loading={isRowLoading}
|
||||
onClick={async () => {
|
||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
||||
|
||||
@ -14,6 +16,10 @@ type TemplatesPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Templates',
|
||||
};
|
||||
|
||||
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
const page = Number(searchParams.page) || 1;
|
||||
|
||||
@ -30,7 +30,7 @@ export const DocumentPreviewButton = ({
|
||||
{...props}
|
||||
>
|
||||
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
|
||||
View Document
|
||||
View Original Document
|
||||
</Button>
|
||||
|
||||
<DocumentDialog documentData={documentData} open={showDialog} onOpenChange={setShowDialog} />
|
||||
|
||||
@ -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 { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
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 { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
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">
|
||||
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>
|
||||
</h2>
|
||||
|
||||
@ -128,7 +131,7 @@ export default async function CompletedSigningPage({
|
||||
/>
|
||||
) : (
|
||||
<DocumentPreviewButton
|
||||
className="flex-1"
|
||||
className="text-[11px]"
|
||||
title="Signatures will appear once the document has been completed"
|
||||
documentData={documentData}
|
||||
/>
|
||||
|
||||
@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
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 { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -96,73 +96,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
)}
|
||||
>
|
||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
||||
<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>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Please review the document before signing.
|
||||
</p>
|
||||
{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" />
|
||||
<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>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
<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>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
<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">
|
||||
Please review the document before signing.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
<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>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
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 { NextAuthProvider } from '~/providers/next-auth';
|
||||
@ -12,10 +14,16 @@ export type SigningLayoutProps = {
|
||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
let teams: GetTeamsResponse = [];
|
||||
|
||||
if (user && session) {
|
||||
teams = await getTeams({ userId: user.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<div className="min-h-screen">
|
||||
{user && <AuthenticatedHeader user={user} />}
|
||||
{user && <AuthenticatedHeader user={user} teams={teams} />}
|
||||
|
||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||
</div>
|
||||
|
||||
@ -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 { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Document, Field } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@ -17,6 +18,7 @@ export type SignDialogProps = {
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
role: RecipientRole;
|
||||
};
|
||||
|
||||
export const SignDialog = ({
|
||||
@ -25,6 +27,7 @@ export const SignDialog = ({
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
role,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
@ -45,9 +48,18 @@ export const SignDialog = ({
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-semibold text-neutral-800">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">
|
||||
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>
|
||||
|
||||
@ -71,7 +83,9 @@ export const SignDialog = ({
|
||||
loading={isSubmitting}
|
||||
onClick={onSignatureComplete}
|
||||
>
|
||||
Sign
|
||||
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
||||
{role === RecipientRole.SIGNER && 'Sign'}
|
||||
{role === RecipientRole.APPROVER && 'Approve'}
|
||||
</Button>
|
||||
</div>
|
||||
</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 DocumentPageComponent 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 <DocumentPageComponent 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>
|
||||
);
|
||||
};
|
||||
@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Forgot password',
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Forgot Password',
|
||||
};
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Reset Password',
|
||||
};
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@ -1,10 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
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';
|
||||
|
||||
export default function SignInPage() {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign In',
|
||||
};
|
||||
|
||||
type SignInPageProps = {
|
||||
searchParams: {
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||
|
||||
if (!email && rawEmail) {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||
@ -13,7 +33,11 @@ export default function SignInPage() {
|
||||
Welcome back, we are lucky to have you.
|
||||
</p>
|
||||
|
||||
<SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||
<SignInForm
|
||||
className="mt-4"
|
||||
initialEmail={email || undefined}
|
||||
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||
/>
|
||||
|
||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||
|
||||
@ -1,15 +1,34 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
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';
|
||||
|
||||
export default function SignUpPage() {
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign Up',
|
||||
};
|
||||
|
||||
type SignUpPageProps = {
|
||||
searchParams: {
|
||||
email?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
redirect('/signin');
|
||||
}
|
||||
|
||||
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||
|
||||
if (!email && rawEmail) {
|
||||
redirect('/signup');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||
@ -19,7 +38,11 @@ export default function SignUpPage() {
|
||||
signing is within your grasp.
|
||||
</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">
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { XCircle } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Verify Email',
|
||||
};
|
||||
|
||||
export default function EmailVerificationWithoutTokenPage() {
|
||||
return (
|
||||
<div className="flex w-full items-start">
|
||||
|
||||
@ -20,7 +20,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||
|
||||
export const metadata = {
|
||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
||||
title: {
|
||||
template: '%s - Documenso',
|
||||
default: 'Documenso',
|
||||
},
|
||||
description:
|
||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||
keywords:
|
||||
|
||||
@ -4,6 +4,7 @@ import React from 'react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
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 type { Recipient } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
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 type { Recipient } from '@documenso/prisma/client';
|
||||
import {
|
||||
@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
|
||||
type={getRecipientType(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>
|
||||
|
||||
@ -197,20 +197,22 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
)}
|
||||
{!currentPage && (
|
||||
<>
|
||||
<CommandGroup heading="Documents">
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Documents">
|
||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Templates">
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Templates">
|
||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Settings">
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Settings">
|
||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Preferences">
|
||||
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Preferences">
|
||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
|
||||
Change theme
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
{searchResults.length > 0 && (
|
||||
<CommandGroup heading="Your documents">
|
||||
<CommandGroup className="mx-2 p-0 pb-2" heading="Your documents">
|
||||
<Commands push={push} pages={searchResults} />
|
||||
</CommandGroup>
|
||||
)}
|
||||
@ -231,6 +233,7 @@ const Commands = ({
|
||||
}) => {
|
||||
return pages.map((page, idx) => (
|
||||
<CommandItem
|
||||
className="-mx-2 -my-1 rounded-lg"
|
||||
key={page.path + idx}
|
||||
value={page.value ?? page.label}
|
||||
onSelect={() => push(page.path)}
|
||||
@ -255,7 +258,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
|
||||
<CommandItem
|
||||
key={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.label}
|
||||
|
||||
@ -4,10 +4,11 @@ import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { getRootHref } from '@documenso/lib/utils/params';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
@ -28,10 +29,13 @@ export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||
|
||||
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||
|
||||
useEffect(() => {
|
||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
||||
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
||||
@ -48,20 +52,24 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-baseline gap-x-6">
|
||||
{navigationLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
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-foreground dark:text-muted-foreground': pathname?.startsWith(href),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
{navigationLinks
|
||||
.filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages.
|
||||
.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={`${rootHref}${href}`}
|
||||
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-foreground dark:text-muted-foreground': pathname?.startsWith(
|
||||
`${rootHref}${href}`,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||
|
||||
@ -1,23 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||
|
||||
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 { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
|
||||
import { CommandMenu } from '../common/command-menu';
|
||||
import { DesktopNav } from './desktop-nav';
|
||||
import { ProfileDropdown } from './profile-dropdown';
|
||||
import { MenuSwitcher } from './menu-switcher';
|
||||
import { MobileNavigation } from './mobile-navigation';
|
||||
|
||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
user: User;
|
||||
teams: GetTeamsResponse;
|
||||
};
|
||||
|
||||
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||
const params = useParams();
|
||||
|
||||
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@ -33,7 +44,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[50] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||
scrollY > 5 && 'border-b-border',
|
||||
className,
|
||||
)}
|
||||
@ -41,8 +52,8 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
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>
|
||||
@ -50,11 +61,24 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
||||
<DesktopNav />
|
||||
|
||||
<div className="flex gap-x-4 md:ml-8">
|
||||
<ProfileDropdown user={user} />
|
||||
<MenuSwitcher user={user} teams={teams} />
|
||||
</div>
|
||||
|
||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
||||
<Menu className="h-6 w-6" />
|
||||
</Button> */}
|
||||
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||
<SearchIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<button onClick={() => setIsHamburgerMenuOpen(true)}>
|
||||
<MenuIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<CommandMenu open={isCommandMenuOpen} onOpenChange={setIsCommandMenuOpen} />
|
||||
|
||||
<MobileNavigation
|
||||
isMenuOpen={isHamburgerMenuOpen}
|
||||
onMenuOpenChange={setIsHamburgerMenuOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
214
apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
Normal file
214
apps/web/src/components/(dashboard)/layout/menu-switcher.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
'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 } 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];
|
||||
};
|
||||
|
||||
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="/">
|
||||
<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={`/t/${team.url}`}>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
secondaryText={formatSecondaryAvatarText(team)}
|
||||
rightSideComponent={
|
||||
isPathTeamUrl(team.url) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
|
||||
<Link
|
||||
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',
|
||||
},
|
||||
].filter(({ text, href }) => text !== 'Templates' || href === '/templates'); // Filter out templates for teams.
|
||||
|
||||
return (
|
||||
<Sheet open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<SheetContent className="flex w-full max-w-[400px] flex-col">
|
||||
<Link href="/" className="z-10" 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>
|
||||
);
|
||||
};
|
||||
@ -1,169 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CreditCard,
|
||||
FileSpreadsheet,
|
||||
Lock,
|
||||
LogOut,
|
||||
User as LucideUser,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
Sun,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { LuGithub } from 'react-icons/lu';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
export type ProfileDropdownProps = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
||||
const { getFlag } = useFeatureFlags();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const isBillingEnabled = getFlag('app_billing');
|
||||
|
||||
const avatarFallback = user.name
|
||||
? recipientInitials(user.name)
|
||||
: user.email.slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Profile Dropdown"
|
||||
className="relative h-10 w-10 rounded-full"
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback>{avatarFallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||
|
||||
{isUserAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="cursor-pointer">
|
||||
<UserCog className="mr-2 h-4 w-4" />
|
||||
Admin
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="cursor-pointer">
|
||||
<LucideUser className="mr-2 h-4 w-4" />
|
||||
Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/security" className="cursor-pointer">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Security
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isBillingEnabled && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/billing" className="cursor-pointer">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Billing
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/templates" className="cursor-pointer">
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Templates
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Themes
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
||||
<DropdownMenuRadioItem value="light">
|
||||
<Sun className="mr-2 h-4 w-4" /> Light
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
Dark
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system">
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
System
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="https://github.com/documenso/documenso"
|
||||
className="cursor-pointer"
|
||||
target="_blank"
|
||||
>
|
||||
<LuGithub className="mr-2 h-4 w-4" />
|
||||
Star on Github
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
void signOut({
|
||||
callbackUrl: '/',
|
||||
})
|
||||
}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -21,9 +21,9 @@ export const PeriodSelector = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const period = useMemo(() => {
|
||||
const p = searchParams?.get('period') ?? '';
|
||||
const p = searchParams?.get('period') ?? 'all';
|
||||
|
||||
return isPeriodSelectorValue(p) ? p : '';
|
||||
return isPeriodSelectorValue(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onPeriodChange = (newPeriod: string) => {
|
||||
@ -35,7 +35,7 @@ export const PeriodSelector = () => {
|
||||
|
||||
params.set('period', newPeriod);
|
||||
|
||||
if (newPeriod === '') {
|
||||
if (newPeriod === '' || newPeriod === 'all') {
|
||||
params.delete('period');
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ export const PeriodSelector = () => {
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="">All Time</SelectItem>
|
||||
<SelectItem value="all">All Time</SelectItem>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="14d">Last 14 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 => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Teams
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
export type SettingsHeaderProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export 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';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { CreditCard, Lock, User } from 'lucide-react';
|
||||
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -38,6 +38,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/teams">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
Teams
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/security">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { 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,177 @@
|
||||
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"
|
||||
loading={isCreatingCheckout}
|
||||
onClick={async () =>
|
||||
createCheckout({
|
||||
interval: selectedPrice.interval,
|
||||
pendingTeamId,
|
||||
})
|
||||
}
|
||||
>
|
||||
Checkout
|
||||
</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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user