mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: add teams (#848)
## Description Add support for teams which will allow users to collaborate on documents. Teams features allows users to: - Create, manage and transfer teams - Manage team members - Manage team emails - Manage a shared team inbox and documents These changes do NOT include the following, which are planned for a future release: - Team templates - Team API - Search menu integration ## Testing Performed - Added E2E tests for general team management - Added E2E tests to validate document counts ## Checklist - [X] I have tested these changes locally and they work as expected. - [X] I have added/updated tests that prove the effectiveness of these changes. - [ ] I have updated the documentation to reflect these changes, if applicable. - [X] I have followed the project's coding style guidelines.
This commit is contained in:
BIN
apps/web/public/static/add-user.png
Normal file
BIN
apps/web/public/static/add-user.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
apps/web/public/static/mail-open-alert.png
Normal file
BIN
apps/web/public/static/mail-open-alert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web/public/static/mail-open.png
Normal file
BIN
apps/web/public/static/mail-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@ -7,9 +7,9 @@ import Link from 'next/link';
|
|||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Document, User } from '@documenso/prisma/client';
|
import type { Document, User } from '@documenso/prisma/client';
|
||||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
accessorKey: 'owner',
|
accessorKey: 'owner',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const avatarFallbackText = row.original.User.name
|
const avatarFallbackText = row.original.User.name
|
||||||
? recipientInitials(row.original.User.name)
|
? extractInitials(row.original.User.name)
|
||||||
: row.original.User.email.slice(0, 1).toUpperCase();
|
: row.original.User.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -19,7 +19,7 @@ type ComboboxProps = {
|
|||||||
onChange: (_values: string[]) => void;
|
onChange: (_values: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||||
const dbRoles = Object.values(Role);
|
const dbRoles = Object.values(Role);
|
||||||
@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { MultiSelectCombobox };
|
export { MultiSelectRoleCombobox };
|
||||||
@ -18,9 +18,10 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { MultiSelectRoleCombobox } from './multiselect-role-combobox';
|
||||||
|
|
||||||
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
|
||||||
|
|
||||||
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
|
||||||
@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
|
|||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2">
|
||||||
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
<FormLabel className="text-muted-foreground">Roles</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<MultiSelectCombobox
|
<MultiSelectRoleCombobox
|
||||||
listValues={roles}
|
listValues={roles}
|
||||||
onChange={(values: string[]) => onChange(values)}
|
onChange={(values: string[]) => onChange(values)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||||
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
|
|
||||||
import { UsersDataTable } from './data-table-users';
|
import { UsersDataTable } from './data-table-users';
|
||||||
import { search } from './fetch-users.actions';
|
import { search } from './fetch-users.actions';
|
||||||
@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
|
|||||||
|
|
||||||
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
const [{ users, totalPages }, individualPrices] = await Promise.all([
|
||||||
search(searchString, page, perPage),
|
search(searchString, page, perPage),
|
||||||
getPricesByType('individual'),
|
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const individualPriceIds = individualPrices.map((price) => price.id);
|
const individualPriceIds = individualPrices.map((price) => price.id);
|
||||||
|
|||||||
@ -0,0 +1,131 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
|
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
||||||
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
|
export type DocumentPageViewProps = {
|
||||||
|
params: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
team?: Team;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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;
|
documentMeta: DocumentMeta | null;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
|
documentRootPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
||||||
@ -45,6 +46,7 @@ export const EditDocumentForm = ({
|
|||||||
documentMeta,
|
documentMeta,
|
||||||
user: _user,
|
user: _user,
|
||||||
documentData,
|
documentData,
|
||||||
|
documentRootPath,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -168,7 +170,7 @@ export const EditDocumentForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push('/documents');
|
router.push(documentRootPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
|
|||||||
@ -1,20 +1,4 @@
|
|||||||
import Link from 'next/link';
|
import DocumentPageView from './document-page-view';
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
|
||||||
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
|
|
||||||
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
export type DocumentPageProps = {
|
export type DocumentPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -22,103 +6,6 @@ export type DocumentPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function DocumentPage({ params }: DocumentPageProps) {
|
export default function DocumentPage({ params }: DocumentPageProps) {
|
||||||
const { id } = params;
|
return <DocumentPageView params={params} />;
|
||||||
|
|
||||||
const documentId = Number(id);
|
|
||||||
|
|
||||||
if (!documentId || Number.isNaN(documentId)) {
|
|
||||||
redirect('/documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const document = await getDocumentById({
|
|
||||||
id: documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
|
||||||
redirect('/documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
|
||||||
|
|
||||||
if (documentMeta?.password) {
|
|
||||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
const securePassword = Buffer.from(
|
|
||||||
symmetricDecrypt({
|
|
||||||
key,
|
|
||||||
data: documentMeta.password,
|
|
||||||
}),
|
|
||||||
).toString('utf-8');
|
|
||||||
|
|
||||||
documentMeta.password = securePassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [recipients, fields] = await Promise.all([
|
|
||||||
getRecipientsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
getFieldsForDocument({
|
|
||||||
documentId,
|
|
||||||
userId: user.id,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
|
||||||
<Link href="/documents" className="flex items-center text-[#7AC455] hover:opacity-80">
|
|
||||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
|
||||||
Documents
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
|
||||||
{document.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<div className="text-muted-foreground flex items-center">
|
|
||||||
<Users2 className="mr-2 h-5 w-5" />
|
|
||||||
|
|
||||||
<StackAvatarsWithTooltip recipients={recipients} position="bottom">
|
|
||||||
<span>{recipients.length} Recipient(s)</span>
|
|
||||||
</StackAvatarsWithTooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{document.status !== InternalDocumentStatus.COMPLETED && (
|
|
||||||
<EditDocumentForm
|
|
||||||
className="mt-8"
|
|
||||||
document={document}
|
|
||||||
user={user}
|
|
||||||
documentMeta={documentMeta}
|
|
||||||
recipients={recipients}
|
|
||||||
fields={fields}
|
|
||||||
documentData={documentData}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{document.status === InternalDocumentStatus.COMPLETED && (
|
|
||||||
<div className="mx-auto mt-12 max-w-2xl">
|
|
||||||
<LazyPDFViewer
|
|
||||||
document={document}
|
|
||||||
key={documentData.id}
|
|
||||||
documentMeta={documentMeta}
|
|
||||||
documentData={documentData}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import * as z from 'zod';
|
|||||||
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
|||||||
const FORM_ID = 'resend-email';
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
export type ResendDocumentActionItemProps = {
|
export type ResendDocumentActionItemProps = {
|
||||||
document: Document;
|
document: Document & {
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
|
};
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ZResendDocumentFormSchema = z.object({
|
export const ZResendDocumentFormSchema = z.object({
|
||||||
@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema
|
|||||||
export const ResendDocumentActionItem = ({
|
export const ResendDocumentActionItem = ({
|
||||||
document,
|
document,
|
||||||
recipients,
|
recipients,
|
||||||
|
team,
|
||||||
}: ResendDocumentActionItemProps) => {
|
}: ResendDocumentActionItemProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isOwner = document.userId === session?.user?.id;
|
const isOwner = document.userId === session?.user?.id;
|
||||||
|
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||||
|
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
!isOwner ||
|
(!isOwner && !isCurrentTeamDocument) ||
|
||||||
document.status !== 'PENDING' ||
|
document.status !== 'PENDING' ||
|
||||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||||
|
|
||||||
@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({
|
|||||||
|
|
||||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||||
try {
|
try {
|
||||||
await resendDocument({ documentId: document.id, recipients });
|
await resendDocument({ documentId: document.id, recipients, teamId: team?.id });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document re-sent',
|
title: 'Document re-sent',
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
@ -18,10 +19,12 @@ export type DataTableActionButtonProps = {
|
|||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -38,6 +41,9 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -46,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
document = await trpcClient.document.getDocumentById.query({
|
document = await trpcClient.document.getDocumentById.query({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
document = await trpcClient.document.getDocumentByToken.query({
|
document = await trpcClient.document.getDocumentByToken.query({
|
||||||
@ -81,15 +88,19 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
isCurrentTeamDocument,
|
||||||
})
|
})
|
||||||
.with({ isOwner: true, isDraft: true }, () => (
|
.with(
|
||||||
<Button className="w-32" asChild>
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
<Link href={`/documents/${row.id}`}>
|
() => (
|
||||||
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
<Button className="w-32" asChild>
|
||||||
Edit
|
<Link href={`${documentsPath}/${row.id}`}>
|
||||||
</Link>
|
<Edit className="-ml-1 mr-2 h-4 w-4" />
|
||||||
</Button>
|
Edit
|
||||||
))
|
</Link>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
)
|
||||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
|||||||
@ -20,8 +20,9 @@ import {
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@ -42,10 +43,12 @@ export type DataTableActionDropdownProps = {
|
|||||||
row: Document & {
|
row: Document & {
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -65,6 +68,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
const isDocumentDeletable = isOwner;
|
const isDocumentDeletable = isOwner;
|
||||||
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -73,6 +79,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
document = await trpcClient.document.getDocumentById.query({
|
document = await trpcClient.document.getDocumentById.query({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
document = await trpcClient.document.getDocumentByToken.query({
|
document = await trpcClient.document.getDocumentByToken.query({
|
||||||
@ -134,8 +141,8 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
<DropdownMenuItem disabled={(!isOwner && !isCurrentTeamDocument) || isComplete} asChild>
|
||||||
<Link href={`/documents/${row.id}`}>
|
<Link href={`${documentsPath}/${row.id}`}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
@ -163,7 +170,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||||
|
|
||||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||||
|
|
||||||
<DocumentShareButton
|
<DocumentShareButton
|
||||||
documentId={row.id}
|
documentId={row.id}
|
||||||
@ -193,6 +200,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
|
team={team}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||||
|
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||||
|
|
||||||
|
type DataTableSenderFilterProps = {
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DataTableSenderFilter = ({ teamId }: DataTableSenderFilterProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const senderIds = parseToIntegerArray(searchParams?.get('senderIds') ?? '');
|
||||||
|
|
||||||
|
const { data, isInitialLoading } = trpc.team.getTeamMembers.useQuery({
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const comboBoxOptions = (data ?? []).map((member) => ({
|
||||||
|
label: member.user.name ?? member.user.email,
|
||||||
|
value: member.user.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onChange = (newSenderIds: number[]) => {
|
||||||
|
if (!pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
params.set('senderIds', newSenderIds.join(','));
|
||||||
|
|
||||||
|
if (newSenderIds.length === 0) {
|
||||||
|
params.delete('senderIds');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelectCombobox
|
||||||
|
emptySelectionPlaceholder={
|
||||||
|
<p className="text-muted-foreground font-normal">
|
||||||
|
<span className="text-muted-foreground/70">Sender:</span> All
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
enableClearAllButton={true}
|
||||||
|
inputPlaceholder="Search"
|
||||||
|
loading={!isMounted || isInitialLoading}
|
||||||
|
options={comboBoxOptions}
|
||||||
|
selectedValues={senderIds}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -7,7 +7,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, Team, User } from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@ -25,11 +25,18 @@ export type DocumentsDataTableProps = {
|
|||||||
Document & {
|
Document & {
|
||||||
Recipient: Recipient[];
|
Recipient: Recipient[];
|
||||||
User: Pick<User, 'id' | 'name' | 'email'>;
|
User: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
showSenderColumn?: boolean;
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
export const DocumentsDataTable = ({
|
||||||
|
results,
|
||||||
|
showSenderColumn,
|
||||||
|
team,
|
||||||
|
}: DocumentsDataTableProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
@ -61,6 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
header: 'Title',
|
header: 'Title',
|
||||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'sender',
|
||||||
|
header: 'Sender',
|
||||||
|
cell: ({ row }) => row.original.User.name ?? row.original.User.email,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Recipient',
|
header: 'Recipient',
|
||||||
accessorKey: 'recipient',
|
accessorKey: 'recipient',
|
||||||
@ -79,8 +91,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
(!row.original.deletedAt ||
|
(!row.original.deletedAt ||
|
||||||
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DataTableActionButton row={row.original} />
|
<DataTableActionButton team={team} row={row.original} />
|
||||||
<DataTableActionDropdown row={row.original} />
|
<DataTableActionDropdown team={team} row={row.original} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@ -90,6 +102,9 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
|
|||||||
currentPage={results.currentPage}
|
currentPage={results.currentPage}
|
||||||
totalPages={results.totalPages}
|
totalPages={results.totalPages}
|
||||||
onPaginationChange={onPaginationChange}
|
onPaginationChange={onPaginationChange}
|
||||||
|
columnVisibility={{
|
||||||
|
sender: Boolean(showSenderColumn),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|||||||
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 { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
import type { Team } from '@documenso/prisma/client';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -16,18 +18,21 @@ type DuplicateDocumentDialogProps = {
|
|||||||
id: number;
|
id: number;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
team?: Pick<Team, 'id' | 'url'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DuplicateDocumentDialog = ({
|
export const DuplicateDocumentDialog = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
team,
|
||||||
}: DuplicateDocumentDialogProps) => {
|
}: DuplicateDocumentDialogProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||||
id,
|
id,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentData = document?.documentData
|
const documentData = document?.documentData
|
||||||
@ -37,10 +42,12 @@ export const DuplicateDocumentDialog = ({
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const documentsPath = formatDocumentsPath(team?.url);
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: (newId) => {
|
onSuccess: (newId) => {
|
||||||
router.push(`/documents/${newId}`);
|
router.push(`${documentsPath}/${newId}`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Document Duplicated',
|
title: 'Document Duplicated',
|
||||||
@ -54,7 +61,7 @@ export const DuplicateDocumentDialog = ({
|
|||||||
|
|
||||||
const onDuplicate = async () => {
|
const onDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
await duplicateDocument({ id });
|
await duplicateDocument({ id, teamId: team?.id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: 'Something went wrong',
|
title: 'Something went wrong',
|
||||||
|
|||||||
@ -1,119 +1,16 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import type { DocumentsPageViewProps } from './documents-page-view';
|
||||||
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
import DocumentsPageView from './documents-page-view';
|
||||||
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 { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
|
||||||
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
|
||||||
import { EmptyDocumentState } from './empty-state';
|
|
||||||
import { UploadDocument } from './upload-document';
|
|
||||||
|
|
||||||
export type DocumentsPageProps = {
|
export type DocumentsPageProps = {
|
||||||
searchParams?: {
|
searchParams?: DocumentsPageViewProps['searchParams'];
|
||||||
status?: ExtendedDocumentStatus;
|
|
||||||
period?: PeriodSelectorValue;
|
|
||||||
page?: string;
|
|
||||||
perPage?: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Documents',
|
title: 'Documents',
|
||||||
};
|
};
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
export default function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
return <DocumentsPageView searchParams={searchParams} />;
|
||||||
const page = Number(searchParams.page) || 1;
|
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
|
||||||
|
|
||||||
const stats = await getStats({
|
|
||||||
user,
|
|
||||||
period,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -21,9 +22,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export type UploadDocumentProps = {
|
export type UploadDocumentProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
team?: {
|
||||||
|
id: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
@ -39,13 +44,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
|
|
||||||
const disabledMessage = useMemo(() => {
|
const disabledMessage = useMemo(() => {
|
||||||
if (remaining.documents === 0) {
|
if (remaining.documents === 0) {
|
||||||
return 'You have reached your document limit.';
|
return team
|
||||||
|
? 'Document upload disabled due to unpaid invoices'
|
||||||
|
: 'You have reached your document limit.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session?.user.emailVerified) {
|
if (!session?.user.emailVerified) {
|
||||||
return 'Verify your email to upload documents.';
|
return 'Verify your email to upload documents.';
|
||||||
}
|
}
|
||||||
}, [remaining.documents, session?.user.emailVerified]);
|
}, [remaining.documents, session?.user.emailVerified, team]);
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
@ -61,6 +68,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
const { id } = await createDocument({
|
const { id } = await createDocument({
|
||||||
title: file.name,
|
title: file.name,
|
||||||
documentDataId,
|
documentDataId,
|
||||||
|
teamId: team?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -75,7 +83,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.push(`/documents/${id}`);
|
router.push(`${formatDocumentsPath(team?.url)}/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
@ -117,11 +125,13 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute -bottom-6 right-0">
|
<div className="absolute -bottom-6 right-0">
|
||||||
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
|
{team?.id === undefined &&
|
||||||
<p className="text-muted-foreground/60 text-xs">
|
remaining.documents > 0 &&
|
||||||
{remaining.documents} of {quota.documents} documents remaining this month.
|
Number.isFinite(remaining.documents) && (
|
||||||
</p>
|
<p className="text-muted-foreground/60 text-xs">
|
||||||
)}
|
{remaining.documents} of {quota.documents} documents remaining this month.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@ -130,7 +140,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{remaining.documents === 0 && (
|
{team?.id === undefined && remaining.documents === 0 && (
|
||||||
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
<h2 className="text-muted-foreground/80 text-xl font-semibold">
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
@ -26,13 +27,17 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const [{ user }, teams] = await Promise.all([
|
||||||
|
getRequiredServerComponentSession(),
|
||||||
|
getTeams({ userId: session.user.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
<Header user={user} />
|
|
||||||
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
import { createBillingPortal } from './create-billing-portal.action';
|
import { createBillingPortal } from './create-billing-portal.action';
|
||||||
|
|
||||||
export const BillingPortalButton = () => {
|
export type BillingPortalButtonProps = {
|
||||||
|
buttonProps?: React.ComponentProps<typeof Button>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BillingPortalButton = ({ buttonProps }: BillingPortalButtonProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
|
||||||
@ -48,7 +52,11 @@ export const BillingPortalButton = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
|
<Button
|
||||||
|
{...buttonProps}
|
||||||
|
onClick={async () => handleFetchPortalUrl()}
|
||||||
|
loading={isFetchingPortalUrl}
|
||||||
|
>
|
||||||
Manage Subscription
|
Manage Subscription
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
|
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
|
||||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
import { type Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
@ -36,23 +37,23 @@ export default async function BillingSettingsPage() {
|
|||||||
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
user = await getStripeCustomerByUser(user).then((result) => result.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [subscriptions, prices, individualPrices] = await Promise.all([
|
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
|
||||||
getSubscriptionsByUserId({ userId: user.id }),
|
getSubscriptionsByUserId({ userId: user.id }),
|
||||||
getPricesByInterval({ type: 'individual' }),
|
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
|
||||||
getPricesByType('individual'),
|
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const individualPriceIds = individualPrices.map(({ id }) => id);
|
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
|
||||||
|
|
||||||
let subscriptionProduct: Stripe.Product | null = null;
|
let subscriptionProduct: Stripe.Product | null = null;
|
||||||
|
|
||||||
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
|
||||||
individualPriceIds.includes(priceId),
|
communityPlanPriceIds.includes(priceId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const subscription =
|
const subscription =
|
||||||
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
|
||||||
individualUserSubscriptions[0];
|
communityPlanUserSubscriptions[0];
|
||||||
|
|
||||||
if (subscription?.priceId) {
|
if (subscription?.priceId) {
|
||||||
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { ProfileForm } from '~/components/forms/profile';
|
import { ProfileForm } from '~/components/forms/profile';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -13,11 +14,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">Profile</h3>
|
<SettingsHeader title="Profile" subtitle="Here you can edit your personal details." />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<ProfileForm user={user} className="max-w-xl" />
|
<ProfileForm user={user} className="max-w-xl" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
@ -19,13 +20,10 @@ export default async function SecuritySettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-2xl font-semibold">Security</h3>
|
<SettingsHeader
|
||||||
|
title="Security"
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
subtitle="Here you can manage your password and security settings."
|
||||||
Here you can manage your password and security settings.
|
/>
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
{user.identityProvider === 'DOCUMENSO' ? (
|
{user.identityProvider === 'DOCUMENSO' ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -83,7 +83,7 @@ export const TemplatesDataTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{remaining.documents === 0 && (
|
{remaining.documents === 0 && (
|
||||||
<Alert className="mb-4 mt-5">
|
<Alert variant="warning" className="mb-4">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>Document Limit Exceeded!</AlertTitle>
|
<AlertTitle>Document Limit Exceeded!</AlertTitle>
|
||||||
<AlertDescription className="mt-2">
|
<AlertDescription className="mt-2">
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
|
||||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
@ -12,10 +14,16 @@ export type SigningLayoutProps = {
|
|||||||
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
export default async function SigningLayout({ children }: SigningLayoutProps) {
|
||||||
const { user, session } = await getServerComponentSession();
|
const { user, session } = await getServerComponentSession();
|
||||||
|
|
||||||
|
let teams: GetTeamsResponse = [];
|
||||||
|
|
||||||
|
if (user && session) {
|
||||||
|
teams = await getTeams({ userId: user.id });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{user && <AuthenticatedHeader user={user} />}
|
{user && <AuthenticatedHeader user={user} teams={teams} />}
|
||||||
|
|
||||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,9 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
|
|
||||||
@ -9,7 +11,20 @@ export const metadata: Metadata = {
|
|||||||
title: 'Sign In',
|
title: 'Sign In',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SignInPage() {
|
type SignInPageProps = {
|
||||||
|
searchParams: {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SignInPage({ searchParams }: SignInPageProps) {
|
||||||
|
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||||
|
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||||
|
|
||||||
|
if (!email && rawEmail) {
|
||||||
|
redirect('/signin');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
|
||||||
@ -18,7 +33,11 @@ export default function SignInPage() {
|
|||||||
Welcome back, we are lucky to have you.
|
Welcome back, we are lucky to have you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
<SignInForm
|
||||||
|
className="mt-4"
|
||||||
|
initialEmail={email || undefined}
|
||||||
|
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||||
|
/>
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import Link from 'next/link';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
|
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
@ -10,11 +11,24 @@ export const metadata: Metadata = {
|
|||||||
title: 'Sign Up',
|
title: 'Sign Up',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SignUpPage() {
|
type SignUpPageProps = {
|
||||||
|
searchParams: {
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SignUpPage({ searchParams }: SignUpPageProps) {
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
|
||||||
|
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
|
||||||
|
|
||||||
|
if (!email && rawEmail) {
|
||||||
|
redirect('/signup');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
<h1 className="text-4xl font-semibold">Create a new account</h1>
|
||||||
@ -24,7 +38,11 @@ export default function SignUpPage() {
|
|||||||
signing is within your grasp.
|
signing is within your grasp.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
<SignUpForm
|
||||||
|
className="mt-4"
|
||||||
|
initialEmail={email || undefined}
|
||||||
|
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
|
||||||
|
/>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
|
|||||||
121
apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
Normal file
121
apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||||
|
import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation';
|
||||||
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { TeamMemberInviteStatus } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
type AcceptInvitationPageProps = {
|
||||||
|
params: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AcceptInvitationPage({
|
||||||
|
params: { token },
|
||||||
|
}: AcceptInvitationPageProps) {
|
||||||
|
const session = await getServerComponentSession();
|
||||||
|
|
||||||
|
const teamMemberInvite = await prisma.teamMemberInvite.findUnique({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamMemberInvite) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
This token is invalid or has expired. Please contact your team for a new invitation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Return</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await getTeamById({ teamId: teamMemberInvite.teamId });
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: {
|
||||||
|
equals: teamMemberInvite.email,
|
||||||
|
mode: 'insensitive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Directly convert the team member invite to a team member if they already have an account.
|
||||||
|
if (user) {
|
||||||
|
await acceptTeamInvitation({ userId: user.id, teamId: team.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// For users who do not exist yet, set the team invite status to accepted, which is checked during
|
||||||
|
// user creation to determine if we should add the user to the team at that time.
|
||||||
|
if (!user && teamMemberInvite.status !== TeamMemberInviteStatus.ACCEPTED) {
|
||||||
|
await prisma.teamMemberInvite.update({
|
||||||
|
where: {
|
||||||
|
id: teamMemberInvite.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: TeamMemberInviteStatus.ACCEPTED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = encryptSecondaryData({
|
||||||
|
data: teamMemberInvite.email,
|
||||||
|
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Team invitation</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
You have been invited by <strong>{team.name}</strong> to join their team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-1 text-sm">
|
||||||
|
To accept this invitation you must create an account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/signup?email=${encodeURIComponent(email)}`}>Create account</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Invitation accepted!</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
You have accepted an invitation from <strong>{team.name}</strong> to join their team.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isSessionUserTheInvitedUser ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Continue</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/signin?email=${encodeURIComponent(email)}`}>Continue to login</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
type VerifyTeamEmailPageProps = {
|
||||||
|
params: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) {
|
||||||
|
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamEmailVerification || isTokenExpired(teamEmailVerification.expiresAt)) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
This link is invalid or has expired. Please contact your team to resend a verification.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Return</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { team } = teamEmailVerification;
|
||||||
|
|
||||||
|
let isTeamEmailVerificationError = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.teamEmailVerification.deleteMany({
|
||||||
|
where: {
|
||||||
|
teamId: team.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.teamEmail.create({
|
||||||
|
data: {
|
||||||
|
teamId: team.id,
|
||||||
|
email: teamEmailVerification.email,
|
||||||
|
name: teamEmailVerification.name,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
isTeamEmailVerificationError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTeamEmailVerificationError) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Team email verification</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Something went wrong while attempting to verify your email address for{' '}
|
||||||
|
<strong>{team.name}</strong>. Please try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Team email verified!</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
You have verified your email address for <strong>{team.name}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Continue</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { transferTeamOwnership } from '@documenso/lib/server-only/team/transfer-team-ownership';
|
||||||
|
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
type VerifyTeamTransferPage = {
|
||||||
|
params: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function VerifyTeamTransferPage({
|
||||||
|
params: { token },
|
||||||
|
}: VerifyTeamTransferPage) {
|
||||||
|
const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
team: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!teamTransferVerification || isTokenExpired(teamTransferVerification.expiresAt)) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Invalid link</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
This link is invalid or has expired. Please contact your team to resend a transfer
|
||||||
|
request.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Return</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { team } = teamTransferVerification;
|
||||||
|
|
||||||
|
let isTransferError = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transferTeamOwnership({ token });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
isTransferError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTransferError) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Team ownership transfer</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Something went wrong while attempting to transfer the ownership of team{' '}
|
||||||
|
<strong>{team.name}</strong> to your. Please try again later or contact support.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Team ownership transferred!</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
The ownership of team <strong>{team.name}</strong> has been successfully transferred to you.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/t/${team.url}/settings`}>Continue</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -197,20 +197,22 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
)}
|
)}
|
||||||
{!currentPage && (
|
{!currentPage && (
|
||||||
<>
|
<>
|
||||||
<CommandGroup heading="Documents">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Documents">
|
||||||
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Templates">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Templates">
|
||||||
<Commands push={push} pages={TEMPLATES_PAGES} />
|
<Commands push={push} pages={TEMPLATES_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Settings">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Settings">
|
||||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandGroup heading="Preferences">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Preferences">
|
||||||
<CommandItem onSelect={() => addPage('theme')}>Change theme</CommandItem>
|
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('theme')}>
|
||||||
|
Change theme
|
||||||
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<CommandGroup heading="Your documents">
|
<CommandGroup className="mx-2 p-0 pb-2" heading="Your documents">
|
||||||
<Commands push={push} pages={searchResults} />
|
<Commands push={push} pages={searchResults} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
@ -231,6 +233,7 @@ const Commands = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return pages.map((page, idx) => (
|
return pages.map((page, idx) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
|
className="-mx-2 -my-1 rounded-lg"
|
||||||
key={page.path + idx}
|
key={page.path + idx}
|
||||||
value={page.value ?? page.label}
|
value={page.value ?? page.label}
|
||||||
onSelect={() => push(page.path)}
|
onSelect={() => push(page.path)}
|
||||||
@ -255,7 +258,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
key={theme.theme}
|
key={theme.theme}
|
||||||
onSelect={() => setTheme(theme.theme)}
|
onSelect={() => setTheme(theme.theme)}
|
||||||
className="mx-2 first:mt-2 last:mb-2"
|
className="-my-1 mx-2 rounded-lg first:mt-2 last:mb-2"
|
||||||
>
|
>
|
||||||
<theme.icon className="mr-2" />
|
<theme.icon className="mr-2" />
|
||||||
{theme.label}
|
{theme.label}
|
||||||
|
|||||||
@ -4,10 +4,11 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getRootHref } from '@documenso/lib/utils/params';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@ -28,10 +29,13 @@ export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
|||||||
|
|
||||||
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
|
||||||
|
|
||||||
|
const rootHref = getRootHref(params, { returnEmptyRootString: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
|
||||||
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
|
||||||
@ -48,20 +52,24 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline gap-x-6">
|
<div className="flex items-baseline gap-x-6">
|
||||||
{navigationLinks.map(({ href, label }) => (
|
{navigationLinks
|
||||||
<Link
|
.filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages.
|
||||||
key={href}
|
.map(({ href, label }) => (
|
||||||
href={href}
|
<Link
|
||||||
className={cn(
|
key={href}
|
||||||
'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',
|
href={`${rootHref}${href}`}
|
||||||
{
|
className={cn(
|
||||||
'text-foreground dark:text-muted-foreground': pathname?.startsWith(href),
|
'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>
|
},
|
||||||
))}
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommandMenu open={open} onOpenChange={setOpen} />
|
<CommandMenu open={open} onOpenChange={setOpen} />
|
||||||
|
|||||||
@ -1,23 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'react';
|
import { type HTMLAttributes, useEffect, useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { MenuIcon, SearchIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||||
|
import { getRootHref } from '@documenso/lib/utils/params';
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Logo } from '~/components/branding/logo';
|
import { Logo } from '~/components/branding/logo';
|
||||||
|
|
||||||
|
import { CommandMenu } from '../common/command-menu';
|
||||||
import { DesktopNav } from './desktop-nav';
|
import { DesktopNav } from './desktop-nav';
|
||||||
import { ProfileDropdown } from './profile-dropdown';
|
import { MenuSwitcher } from './menu-switcher';
|
||||||
|
import { MobileNavigation } from './mobile-navigation';
|
||||||
|
|
||||||
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
export type HeaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
user: User;
|
user: User;
|
||||||
|
teams: GetTeamsResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Header = ({ className, user, ...props }: HeaderProps) => {
|
export const Header = ({ className, user, teams, ...props }: HeaderProps) => {
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false);
|
||||||
|
const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false);
|
||||||
const [scrollY, setScrollY] = useState(0);
|
const [scrollY, setScrollY] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -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">
|
<div className="mx-auto flex w-full max-w-screen-xl items-center justify-between gap-x-4 px-4 md:justify-normal md:px-8">
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href={getRootHref(params)}
|
||||||
className="focus-visible:ring-ring ring-offset-background rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="focus-visible:ring-ring ring-offset-background hidden rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 md:inline"
|
||||||
>
|
>
|
||||||
<Logo className="h-6 w-auto" />
|
<Logo className="h-6 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
@ -50,11 +61,24 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
<DesktopNav />
|
<DesktopNav />
|
||||||
|
|
||||||
<div className="flex gap-x-4 md:ml-8">
|
<div className="flex gap-x-4 md:ml-8">
|
||||||
<ProfileDropdown user={user} />
|
<MenuSwitcher user={user} teams={teams} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
|
<div className="flex flex-row items-center space-x-4 md:hidden">
|
||||||
<Menu className="h-6 w-6" />
|
<button onClick={() => setIsCommandMenuOpen(true)}>
|
||||||
</Button> */}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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="z-[60] 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 className="z-[60]">
|
|
||||||
<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 router = useRouter();
|
||||||
|
|
||||||
const period = useMemo(() => {
|
const period = useMemo(() => {
|
||||||
const p = searchParams?.get('period') ?? '';
|
const p = searchParams?.get('period') ?? 'all';
|
||||||
|
|
||||||
return isPeriodSelectorValue(p) ? p : '';
|
return isPeriodSelectorValue(p) ? p : 'all';
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const onPeriodChange = (newPeriod: string) => {
|
const onPeriodChange = (newPeriod: string) => {
|
||||||
@ -35,7 +35,7 @@ export const PeriodSelector = () => {
|
|||||||
|
|
||||||
params.set('period', newPeriod);
|
params.set('period', newPeriod);
|
||||||
|
|
||||||
if (newPeriod === '') {
|
if (newPeriod === '' || newPeriod === 'all') {
|
||||||
params.delete('period');
|
params.delete('period');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ export const PeriodSelector = () => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
<SelectItem value="">All Time</SelectItem>
|
<SelectItem value="all">All Time</SelectItem>
|
||||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||||
<SelectItem value="14d">Last 14 days</SelectItem>
|
<SelectItem value="14d">Last 14 days</SelectItem>
|
||||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User } from 'lucide-react';
|
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/teams">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-5 w-5" />
|
||||||
|
Teams
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type SettingsHeaderProps = {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsHeader = ({ children, title, subtitle }: SettingsHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">{title}</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm md:mt-2">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Lock, User } from 'lucide-react';
|
import { CreditCard, Lock, User, Users } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -38,6 +38,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/teams">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/teams') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-5 w-5" />
|
||||||
|
Teams
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/security">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { TeamEmail } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type UpdateTeamEmailDialogProps = {
|
||||||
|
teamEmail: TeamEmail;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZUpdateTeamEmailFormSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
|
||||||
|
|
||||||
|
export const UpdateTeamEmailDialog = ({
|
||||||
|
teamEmail,
|
||||||
|
trigger,
|
||||||
|
...props
|
||||||
|
}: UpdateTeamEmailDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TUpdateTeamEmailFormSchema>({
|
||||||
|
resolver: zodResolver(ZUpdateTeamEmailFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: teamEmail.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateTeamEmail } = trpc.team.updateTeamEmail.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name }: TUpdateTeamEmailFormSchema) => {
|
||||||
|
try {
|
||||||
|
await updateTeamEmail({
|
||||||
|
teamId: teamEmail.teamId,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Team email was updated.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting update the team email. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="outline" className="bg-background">
|
||||||
|
Update team email
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update team email</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
To change the email you must remove and add a new email address.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" placeholder="eg. Legal" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" value={teamEmail.email} disabled={true} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||||
|
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type UpdateTeamMemberDialogProps = {
|
||||||
|
currentUserTeamRole: TeamMemberRole;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
teamId: number;
|
||||||
|
teamMemberId: number;
|
||||||
|
teamMemberName: string;
|
||||||
|
teamMemberRole: TeamMemberRole;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZUpdateTeamMemberFormSchema = z.object({
|
||||||
|
role: z.nativeEnum(TeamMemberRole),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ZUpdateTeamMemberSchema = z.infer<typeof ZUpdateTeamMemberFormSchema>;
|
||||||
|
|
||||||
|
export const UpdateTeamMemberDialog = ({
|
||||||
|
currentUserTeamRole,
|
||||||
|
trigger,
|
||||||
|
teamId,
|
||||||
|
teamMemberId,
|
||||||
|
teamMemberName,
|
||||||
|
teamMemberRole,
|
||||||
|
...props
|
||||||
|
}: UpdateTeamMemberDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<ZUpdateTeamMemberSchema>({
|
||||||
|
resolver: zodResolver(ZUpdateTeamMemberFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
role: teamMemberRole,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateTeamMember } = trpc.team.updateTeamMember.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ role }: ZUpdateTeamMemberSchema) => {
|
||||||
|
try {
|
||||||
|
await updateTeamMember({
|
||||||
|
teamId,
|
||||||
|
teamMemberId,
|
||||||
|
data: {
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: `You have updated ${teamMemberName}.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to update this team member. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, teamMemberRole)) {
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'You cannot modify a team member who has a higher role than you.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [open, currentUserTeamRole, teamMemberRole, form, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? <Button variant="secondary">Update team member</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update team member</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are currently updating <span className="font-bold">{teamMemberName}.</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>Role</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="w-full" position="popper">
|
||||||
|
{TEAM_MEMBER_ROLE_HIERARCHY[currentUserTeamRole].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{TEAM_MEMBER_ROLE_MAP[role] ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
173
apps/web/src/components/(teams)/forms/update-team-form.tsx
Normal file
173
apps/web/src/components/(teams)/forms/update-team-form.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZUpdateTeamMutationSchema } from '@documenso/trpc/server/team-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type UpdateTeamDialogProps = {
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
teamUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZUpdateTeamFormSchema = ZUpdateTeamMutationSchema.shape.data.pick({
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TUpdateTeamFormSchema = z.infer<typeof ZUpdateTeamFormSchema>;
|
||||||
|
|
||||||
|
export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(ZUpdateTeamFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: teamName,
|
||||||
|
url: teamUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updateTeam } = trpc.team.updateTeam.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name, url }: TUpdateTeamFormSchema) => {
|
||||||
|
try {
|
||||||
|
await updateTeam({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
},
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Your team has been successfully updated.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url !== teamUrl) {
|
||||||
|
router.push(`${WEBAPP_BASE_URL}/t/${url}/settings`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||||
|
form.setError('url', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'This URL is already in use.',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to update your team. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Team Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mt-4">
|
||||||
|
<FormLabel required>Team URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{!form.formState.errors.url && (
|
||||||
|
<span className="text-foreground/50 text-xs font-normal">
|
||||||
|
{field.value
|
||||||
|
? `${WEBAPP_BASE_URL}/t/${field.value}`
|
||||||
|
: 'A unique URL to identify your team'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{form.formState.isDirty && (
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="transition-opacity"
|
||||||
|
disabled={!form.formState.isDirty}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Update team
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { CreditCard, Settings, Users } from 'lucide-react';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||||
|
|
||||||
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
|
<Link href={settingsPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname === settingsPath && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-5 w-5" />
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={membersPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-5 w-5" />
|
||||||
|
Members
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{IS_BILLING_ENABLED && (
|
||||||
|
<Link href={billingPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="mr-2 h-5 w-5" />
|
||||||
|
Billing
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useParams, usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { CreditCard, Key, User } from 'lucide-react';
|
||||||
|
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||||
|
|
||||||
|
const settingsPath = `/t/${teamUrl}/settings`;
|
||||||
|
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||||
|
const billingPath = `/t/${teamUrl}/settings/billing`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Link href={settingsPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(settingsPath) &&
|
||||||
|
pathname.split('/').length === 4 &&
|
||||||
|
'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User className="mr-2 h-5 w-5" />
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={membersPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Key className="mr-2 h-5 w-5" />
|
||||||
|
Members
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{IS_BILLING_ENABLED && (
|
||||||
|
<Link href={billingPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="mr-2 h-5 w-5" />
|
||||||
|
Billing
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { LeaveTeamDialog } from '../dialogs/leave-team-dialog';
|
||||||
|
|
||||||
|
export const CurrentUserTeamsDataTable = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeams.useQuery(
|
||||||
|
{
|
||||||
|
term: parsedSearchParams.query,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Team',
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link href={`/t/${row.original.url}`} scroll={false}>
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
||||||
|
}
|
||||||
|
secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Role',
|
||||||
|
accessorKey: 'role',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.ownerUserId === row.original.currentTeamMember.userId
|
||||||
|
? 'Owner'
|
||||||
|
: TEAM_MEMBER_ROLE_MAP[row.original.currentTeamMember.role],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Member Since',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
{canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/t/${row.original.url}/settings`}>Manage</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LeaveTeamDialog
|
||||||
|
teamId={row.original.id}
|
||||||
|
teamName={row.original.name}
|
||||||
|
role={row.original.currentTeamMember.role}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={row.original.ownerUserId === row.original.currentTeamMember.userId}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/3 py-4 pr-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||||
|
|
||||||
|
<div className="ml-2 flex flex-grow flex-col">
|
||||||
|
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
|
<Skeleton className="h-10 w-20 rounded" />
|
||||||
|
<Skeleton className="h-10 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type PendingUserTeamsDataTableActionsProps = {
|
||||||
|
className?: string;
|
||||||
|
pendingTeamId: number;
|
||||||
|
onPayClick: (pendingTeamId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PendingUserTeamsDataTableActions = ({
|
||||||
|
className,
|
||||||
|
pendingTeamId,
|
||||||
|
onPayClick,
|
||||||
|
}: PendingUserTeamsDataTableActionsProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTeamPending, isLoading: deletingTeam } =
|
||||||
|
trpc.team.deleteTeamPending.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Pending team deleted.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to delete the pending team. Please try again later.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset disabled={deletingTeam} className={cn('flex justify-end space-x-2', className)}>
|
||||||
|
<Button variant="outline" onClick={() => onPayClick(pendingTeamId)}>
|
||||||
|
Pay
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
loading={deletingTeam}
|
||||||
|
onClick={async () => deleteTeamPending({ pendingTeamId: pendingTeamId })}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { CreateTeamCheckoutDialog } from '../dialogs/create-team-checkout-dialog';
|
||||||
|
import { PendingUserTeamsDataTableActions } from './pending-user-teams-data-table-actions';
|
||||||
|
|
||||||
|
export const PendingUserTeamsDataTable = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [checkoutPendingTeamId, setCheckoutPendingTeamId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamsPending.useQuery(
|
||||||
|
{
|
||||||
|
term: parsedSearchParams.query,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const searchParamCheckout = searchParams?.get('checkout');
|
||||||
|
|
||||||
|
if (searchParamCheckout && !isNaN(parseInt(searchParamCheckout))) {
|
||||||
|
setCheckoutPendingTeamId(parseInt(searchParamCheckout));
|
||||||
|
updateSearchParams({ checkout: null });
|
||||||
|
}
|
||||||
|
}, [searchParams, updateSearchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Team',
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
||||||
|
}
|
||||||
|
secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Created on',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<PendingUserTeamsDataTableActions
|
||||||
|
className="justify-end"
|
||||||
|
pendingTeamId={row.original.id}
|
||||||
|
onPayClick={setCheckoutPendingTeamId}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/3 py-4 pr-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||||
|
|
||||||
|
<div className="ml-2 flex flex-grow flex-col">
|
||||||
|
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
|
<Skeleton className="h-10 w-16 rounded" />
|
||||||
|
<Skeleton className="h-10 w-20 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<CreateTeamCheckoutDialog
|
||||||
|
pendingTeamId={checkoutPendingTeamId}
|
||||||
|
onClose={() => setCheckoutPendingTeamId(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { File } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
export type TeamBillingInvoicesDataTableProps = {
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamBillingInvoicesDataTable = ({ teamId }: TeamBillingInvoicesDataTableProps) => {
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamInvoices.useQuery(
|
||||||
|
{
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatCurrency = (currency: string, amount: number) => {
|
||||||
|
const formatter = new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
data: data?.data ?? [],
|
||||||
|
perPage: 100,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Invoice',
|
||||||
|
accessorKey: 'created',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex max-w-xs items-center gap-2">
|
||||||
|
<File className="h-6 w-6" />
|
||||||
|
|
||||||
|
<div className="flex flex-col text-sm">
|
||||||
|
<span className="text-foreground/80 font-semibold">
|
||||||
|
{DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{row.original.quantity} {row.original.quantity > 1 ? 'Seats' : 'Seat'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { status, paid } = row.original;
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return paid ? 'Paid' : 'Unpaid';
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Amount',
|
||||||
|
accessorKey: 'total',
|
||||||
|
cell: ({ row }) => formatCurrency(row.original.currency, row.original.total / 100),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
disabled={typeof row.original.hostedInvoicePdf !== 'string'}
|
||||||
|
>
|
||||||
|
<Link href={row.original.hostedInvoicePdf ?? ''} target="_blank">
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
disabled={typeof row.original.hostedInvoicePdf !== 'string'}
|
||||||
|
>
|
||||||
|
<Link href={row.original.invoicePdf ?? ''} target="_blank">
|
||||||
|
Download
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/3 py-4 pr-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-7 w-7 flex-shrink-0 rounded" />
|
||||||
|
|
||||||
|
<div className="ml-2 flex flex-grow flex-col">
|
||||||
|
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
|
<Skeleton className="h-10 w-20 rounded" />
|
||||||
|
<Skeleton className="h-10 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { History, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
export type TeamMemberInvitesDataTableProps = {
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamMemberInvitesDataTable = ({ teamId }: TeamMemberInvitesDataTableProps) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||||
|
trpc.team.findTeamMemberInvites.useQuery(
|
||||||
|
{
|
||||||
|
teamId,
|
||||||
|
term: parsedSearchParams.query,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: resendTeamMemberInvitation } =
|
||||||
|
trpc.team.resendTeamMemberInvitation.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Invitation has been resent',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Unable to resend invitation. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteTeamMemberInvitations } =
|
||||||
|
trpc.team.deleteTeamMemberInvitations.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Invitation has been deleted',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'Unable to delete invitation. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Team Member',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={row.original.email.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">{row.original.email}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Role',
|
||||||
|
accessorKey: 'role',
|
||||||
|
cell: ({ row }) => TEAM_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Invited At',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () =>
|
||||||
|
resendTeamMemberInvitation({
|
||||||
|
teamId,
|
||||||
|
invitationId: row.original.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<History className="mr-2 h-4 w-4" />
|
||||||
|
Resend
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () =>
|
||||||
|
deleteTeamMemberInvitations({
|
||||||
|
teamId,
|
||||||
|
invitationIds: [row.original.id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/2 py-4 pr-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||||
|
<Skeleton className="ml-2 h-4 w-1/3 max-w-[10rem]" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-6 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,209 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
|
||||||
|
import type { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
import { DeleteTeamMemberDialog } from '../dialogs/delete-team-member-dialog';
|
||||||
|
import { UpdateTeamMemberDialog } from '../dialogs/update-team-member-dialog';
|
||||||
|
|
||||||
|
export type TeamMembersDataTableProps = {
|
||||||
|
currentUserTeamRole: TeamMemberRole;
|
||||||
|
teamOwnerUserId: number;
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamMembersDataTable = ({
|
||||||
|
currentUserTeamRole,
|
||||||
|
teamOwnerUserId,
|
||||||
|
teamId,
|
||||||
|
teamName,
|
||||||
|
}: TeamMembersDataTableProps) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.team.findTeamMembers.useQuery(
|
||||||
|
{
|
||||||
|
teamId,
|
||||||
|
term: parsedSearchParams.query,
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Team Member',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const avatarFallbackText = row.original.user.name
|
||||||
|
? extractInitials(row.original.user.name)
|
||||||
|
: row.original.user.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={avatarFallbackText}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">{row.original.user.name}</span>
|
||||||
|
}
|
||||||
|
secondaryText={row.original.user.email}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Role',
|
||||||
|
accessorKey: 'role',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
teamOwnerUserId === row.original.userId
|
||||||
|
? 'Owner'
|
||||||
|
: TEAM_MEMBER_ROLE_MAP[row.original.role],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Member Since',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<UpdateTeamMemberDialog
|
||||||
|
currentUserTeamRole={currentUserTeamRole}
|
||||||
|
teamId={row.original.teamId}
|
||||||
|
teamMemberId={row.original.id}
|
||||||
|
teamMemberName={row.original.user.name ?? ''}
|
||||||
|
teamMemberRole={row.original.role}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={
|
||||||
|
teamOwnerUserId === row.original.userId ||
|
||||||
|
!isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role)
|
||||||
|
}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
title="Update team member role"
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Update role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteTeamMemberDialog
|
||||||
|
teamId={teamId}
|
||||||
|
teamName={teamName}
|
||||||
|
teamMemberId={row.original.id}
|
||||||
|
teamMemberName={row.original.user.name ?? ''}
|
||||||
|
teamMemberEmail={row.original.user.email}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
disabled={
|
||||||
|
teamOwnerUserId === row.original.userId ||
|
||||||
|
!isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role)
|
||||||
|
}
|
||||||
|
title="Remove team member"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/2 py-4 pr-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||||
|
|
||||||
|
<div className="ml-2 flex flex-grow flex-col">
|
||||||
|
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-6 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
|
import type { TeamMemberRole } from '@documenso/prisma/client';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { TeamMemberInvitesDataTable } from '~/components/(teams)/tables/team-member-invites-data-table';
|
||||||
|
import { TeamMembersDataTable } from '~/components/(teams)/tables/team-members-data-table';
|
||||||
|
|
||||||
|
export type TeamsMemberPageDataTableProps = {
|
||||||
|
currentUserTeamRole: TeamMemberRole;
|
||||||
|
teamId: number;
|
||||||
|
teamName: string;
|
||||||
|
teamOwnerUserId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamsMemberPageDataTable = ({
|
||||||
|
currentUserTeamRole,
|
||||||
|
teamId,
|
||||||
|
teamName,
|
||||||
|
teamOwnerUserId,
|
||||||
|
}: TeamsMemberPageDataTableProps) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||||
|
|
||||||
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||||
|
|
||||||
|
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle debouncing the search query.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
params.set('query', debouncedSearchQuery);
|
||||||
|
|
||||||
|
if (debouncedSearchQuery === '') {
|
||||||
|
params.delete('query');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}, [debouncedSearchQuery, pathname, router, searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
||||||
|
<Input
|
||||||
|
defaultValue={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="members" asChild>
|
||||||
|
<Link href={pathname ?? '/'}>All</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
|
||||||
|
<Link href={`${pathname}?tab=invites`}>Pending</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentTab === 'invites' ? (
|
||||||
|
<TeamMemberInvitesDataTable key="invites" teamId={teamId} />
|
||||||
|
) : (
|
||||||
|
<TeamMembersDataTable
|
||||||
|
key="members"
|
||||||
|
currentUserTeamRole={currentUserTeamRole}
|
||||||
|
teamId={teamId}
|
||||||
|
teamName={teamName}
|
||||||
|
teamOwnerUserId={teamOwnerUserId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
|
import { CurrentUserTeamsDataTable } from './current-user-teams-data-table';
|
||||||
|
import { PendingUserTeamsDataTable } from './pending-user-teams-data-table';
|
||||||
|
|
||||||
|
export const UserSettingsTeamsPageDataTable = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
|
||||||
|
|
||||||
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
|
||||||
|
|
||||||
|
const currentTab = searchParams?.get('tab') === 'pending' ? 'pending' : 'active';
|
||||||
|
|
||||||
|
const { data } = trpc.team.findTeamsPending.useQuery(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle debouncing the search query.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pathname) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(searchParams?.toString());
|
||||||
|
|
||||||
|
params.set('query', debouncedSearchQuery);
|
||||||
|
|
||||||
|
if (debouncedSearchQuery === '') {
|
||||||
|
params.delete('query');
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}, [debouncedSearchQuery, pathname, router, searchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
||||||
|
<Input
|
||||||
|
defaultValue={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="active" asChild>
|
||||||
|
<Link href={pathname ?? '/'}>Active</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="pending" asChild>
|
||||||
|
<Link href={`${pathname}?tab=pending`}>
|
||||||
|
Pending
|
||||||
|
{data && data.count > 0 && (
|
||||||
|
<span className="ml-1 hidden opacity-50 md:inline-block">{data.count}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentTab === 'pending' ? <PendingUserTeamsDataTable /> : <CurrentUserTeamsDataTable />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
'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 TeamBillingPortalButtonProps = {
|
||||||
|
buttonProps?: React.ComponentProps<typeof Button>;
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamBillingPortalButton = ({ buttonProps, teamId }: TeamBillingPortalButtonProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: createBillingPortal, isLoading } =
|
||||||
|
trpc.team.createBillingPortal.useMutation();
|
||||||
|
|
||||||
|
const handleCreatePortal = async () => {
|
||||||
|
try {
|
||||||
|
const sessionUrl = await createBillingPortal({ teamId });
|
||||||
|
|
||||||
|
window.open(sessionUrl, '_blank');
|
||||||
|
} 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button {...buttonProps} onClick={async () => handleCreatePortal()} loading={isLoading}>
|
||||||
|
Manage subscription
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -55,10 +55,11 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
|||||||
|
|
||||||
export type SignInFormProps = {
|
export type SignInFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => {
|
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -69,7 +70,7 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
|
|||||||
|
|
||||||
const form = useForm<TSignInFormSchema>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: '',
|
email: initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
totpCode: '',
|
totpCode: '',
|
||||||
backupCode: '',
|
backupCode: '',
|
||||||
|
|||||||
@ -48,17 +48,18 @@ export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
|||||||
|
|
||||||
export type SignUpFormProps = {
|
export type SignUpFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => {
|
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: initialEmail ?? '',
|
||||||
password: '',
|
password: '',
|
||||||
signature: '',
|
signature: '',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,14 +1,62 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { cookies } from 'next/headers';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
import { getToken } from 'next-auth/jwt';
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
|
import { TEAM_URL_ROOT_REGEX } from '@documenso/lib/constants/teams';
|
||||||
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
export default async function middleware(req: NextRequest) {
|
export default async function middleware(req: NextRequest) {
|
||||||
|
const preferredTeamUrl = cookies().get('preferred-team-url');
|
||||||
|
|
||||||
|
const referrer = req.headers.get('referer');
|
||||||
|
const referrerUrl = referrer ? new URL(referrer) : null;
|
||||||
|
const referrerPathname = referrerUrl ? referrerUrl.pathname : null;
|
||||||
|
|
||||||
|
// Whether to reset the preferred team url cookie if the user accesses a non team page from a team page.
|
||||||
|
const resetPreferredTeamUrl =
|
||||||
|
referrerPathname &&
|
||||||
|
referrerPathname.startsWith('/t/') &&
|
||||||
|
(!req.nextUrl.pathname.startsWith('/t/') || req.nextUrl.pathname === '/');
|
||||||
|
|
||||||
|
// Redirect root page to `/documents` or `/t/{preferredTeamUrl}/documents`.
|
||||||
if (req.nextUrl.pathname === '/') {
|
if (req.nextUrl.pathname === '/') {
|
||||||
const redirectUrl = new URL('/documents', req.url);
|
const redirectUrlPath = formatDocumentsPath(
|
||||||
|
resetPreferredTeamUrl ? undefined : preferredTeamUrl?.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const redirectUrl = new URL(redirectUrlPath, req.url);
|
||||||
|
const response = NextResponse.redirect(redirectUrl);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect `/t` to `/settings/teams`.
|
||||||
|
if (req.nextUrl.pathname === '/t') {
|
||||||
|
const redirectUrl = new URL('/settings/teams', req.url);
|
||||||
|
|
||||||
return NextResponse.redirect(redirectUrl);
|
return NextResponse.redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect `/t/<team_url>` to `/t/<team_url>/documents`.
|
||||||
|
if (TEAM_URL_ROOT_REGEX.test(req.nextUrl.pathname)) {
|
||||||
|
const redirectUrl = new URL(`${req.nextUrl.pathname}/documents`, req.url);
|
||||||
|
|
||||||
|
const response = NextResponse.redirect(redirectUrl);
|
||||||
|
response.cookies.set('preferred-team-url', req.nextUrl.pathname.replace('/t/', ''));
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the preferred team url cookie if user accesses a team page.
|
||||||
|
if (req.nextUrl.pathname.startsWith('/t/')) {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.cookies.set('preferred-team-url', req.nextUrl.pathname.split('/')[2]);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.nextUrl.pathname.startsWith('/signin')) {
|
if (req.nextUrl.pathname.startsWith('/signin')) {
|
||||||
const token = await getToken({ req });
|
const token = await getToken({ req });
|
||||||
|
|
||||||
@ -19,5 +67,34 @@ export default async function middleware(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear preferred team url cookie if user accesses a non team page from a team page.
|
||||||
|
if (resetPreferredTeamUrl || req.nextUrl.pathname === '/documents') {
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.cookies.set('preferred-team-url', '');
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api (API routes)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
* - ingest (analytics)
|
||||||
|
* - site.webmanifest
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
source: '/((?!api|_next/static|_next/image|ingest|favicon|site.webmanifest).*)',
|
||||||
|
missing: [
|
||||||
|
{ type: 'header', key: 'next-router-prefetch' },
|
||||||
|
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|||||||
133
package-lock.json
generated
133
package-lock.json
generated
@ -4886,9 +4886,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-select": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "1.2.2",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
|
||||||
"integrity": "sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==",
|
"integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.13.10",
|
"@babel/runtime": "^7.13.10",
|
||||||
"@radix-ui/number": "1.0.1",
|
"@radix-ui/number": "1.0.1",
|
||||||
@ -4897,12 +4897,12 @@
|
|||||||
"@radix-ui/react-compose-refs": "1.0.1",
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
"@radix-ui/react-context": "1.0.1",
|
"@radix-ui/react-context": "1.0.1",
|
||||||
"@radix-ui/react-direction": "1.0.1",
|
"@radix-ui/react-direction": "1.0.1",
|
||||||
"@radix-ui/react-dismissable-layer": "1.0.4",
|
"@radix-ui/react-dismissable-layer": "1.0.5",
|
||||||
"@radix-ui/react-focus-guards": "1.0.1",
|
"@radix-ui/react-focus-guards": "1.0.1",
|
||||||
"@radix-ui/react-focus-scope": "1.0.3",
|
"@radix-ui/react-focus-scope": "1.0.4",
|
||||||
"@radix-ui/react-id": "1.0.1",
|
"@radix-ui/react-id": "1.0.1",
|
||||||
"@radix-ui/react-popper": "1.1.2",
|
"@radix-ui/react-popper": "1.1.3",
|
||||||
"@radix-ui/react-portal": "1.0.3",
|
"@radix-ui/react-portal": "1.0.4",
|
||||||
"@radix-ui/react-primitive": "1.0.3",
|
"@radix-ui/react-primitive": "1.0.3",
|
||||||
"@radix-ui/react-slot": "1.0.2",
|
"@radix-ui/react-slot": "1.0.2",
|
||||||
"@radix-ui/react-use-callback-ref": "1.0.1",
|
"@radix-ui/react-use-callback-ref": "1.0.1",
|
||||||
@ -4928,113 +4928,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-dismissable-layer": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.13.10",
|
|
||||||
"@radix-ui/primitive": "1.0.1",
|
|
||||||
"@radix-ui/react-compose-refs": "1.0.1",
|
|
||||||
"@radix-ui/react-primitive": "1.0.3",
|
|
||||||
"@radix-ui/react-use-callback-ref": "1.0.1",
|
|
||||||
"@radix-ui/react-use-escape-keydown": "1.0.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-focus-scope": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-upXdPfqI4islj2CslyfUBNlaJCPybbqRHAi1KER7Isel9Q2AtSJ0zRBZv8mWQiFXD2nyAJ4BhC3yXgZ6kMBSrQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.13.10",
|
|
||||||
"@radix-ui/react-compose-refs": "1.0.1",
|
|
||||||
"@radix-ui/react-primitive": "1.0.3",
|
|
||||||
"@radix-ui/react-use-callback-ref": "1.0.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-popper": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.13.10",
|
|
||||||
"@floating-ui/react-dom": "^2.0.0",
|
|
||||||
"@radix-ui/react-arrow": "1.0.3",
|
|
||||||
"@radix-ui/react-compose-refs": "1.0.1",
|
|
||||||
"@radix-ui/react-context": "1.0.1",
|
|
||||||
"@radix-ui/react-primitive": "1.0.3",
|
|
||||||
"@radix-ui/react-use-callback-ref": "1.0.1",
|
|
||||||
"@radix-ui/react-use-layout-effect": "1.0.1",
|
|
||||||
"@radix-ui/react-use-rect": "1.0.1",
|
|
||||||
"@radix-ui/react-use-size": "1.0.1",
|
|
||||||
"@radix-ui/rect": "1.0.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-portal": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.13.10",
|
|
||||||
"@radix-ui/react-primitive": "1.0.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-separator": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz",
|
||||||
@ -19750,13 +19643,19 @@
|
|||||||
"@prisma/client": "5.4.2",
|
"@prisma/client": "5.4.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"dotenv-cli": "^7.3.0",
|
"dotenv-cli": "^7.3.0",
|
||||||
"prisma": "5.4.2"
|
"prisma": "5.4.2",
|
||||||
|
"ts-pattern": "^5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/prisma/node_modules/ts-pattern": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-Y+jOjihlFriWzcBjncPCf2/am+Hgz7LtsWs77pWg5vQQKLQj07oNrJryo/wK2G0ndNaoVn2ownFMeoeAuReu3Q=="
|
||||||
|
},
|
||||||
"packages/prisma/node_modules/typescript": {
|
"packages/prisma/node_modules/typescript": {
|
||||||
"version": "5.2.2",
|
"version": "5.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz",
|
||||||
@ -19864,7 +19763,7 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.0.3",
|
"@radix-ui/react-checkbox": "^1.0.3",
|
||||||
"@radix-ui/react-collapsible": "^1.0.2",
|
"@radix-ui/react-collapsible": "^1.0.2",
|
||||||
"@radix-ui/react-context-menu": "^2.1.3",
|
"@radix-ui/react-context-menu": "^2.1.3",
|
||||||
"@radix-ui/react-dialog": "^1.0.3",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||||
"@radix-ui/react-hover-card": "^1.0.5",
|
"@radix-ui/react-hover-card": "^1.0.5",
|
||||||
"@radix-ui/react-label": "^2.0.1",
|
"@radix-ui/react-label": "^2.0.1",
|
||||||
@ -19874,7 +19773,7 @@
|
|||||||
"@radix-ui/react-progress": "^1.0.2",
|
"@radix-ui/react-progress": "^1.0.2",
|
||||||
"@radix-ui/react-radio-group": "^1.1.2",
|
"@radix-ui/react-radio-group": "^1.1.2",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.3",
|
"@radix-ui/react-scroll-area": "^1.0.3",
|
||||||
"@radix-ui/react-select": "^1.2.1",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-separator": "^1.0.2",
|
"@radix-ui/react-separator": "^1.0.2",
|
||||||
"@radix-ui/react-slider": "^1.1.1",
|
"@radix-ui/react-slider": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
|||||||
40
packages/app-tests/e2e/fixtures/authentication.ts
Normal file
40
packages/app-tests/e2e/fixtures/authentication.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
|
type ManualLoginOptions = {
|
||||||
|
page: Page;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where to navigate after login.
|
||||||
|
*/
|
||||||
|
redirectPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const manualLogin = async ({
|
||||||
|
page,
|
||||||
|
email = 'example@documenso.com',
|
||||||
|
password = 'password',
|
||||||
|
redirectPath,
|
||||||
|
}: ManualLoginOptions) => {
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||||
|
|
||||||
|
await page.getByLabel('Email').click();
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||||
|
await page.getByLabel('Password', { exact: true }).press('Enter');
|
||||||
|
|
||||||
|
if (redirectPath) {
|
||||||
|
await page.waitForURL(`${WEBAPP_BASE_URL}/documents`);
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const manualSignout = async ({ page }: ManualLoginOptions) => {
|
||||||
|
await page.getByTestId('menu-switcher').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
|
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||||
|
};
|
||||||
@ -2,6 +2,8 @@ import { expect, test } from '@playwright/test';
|
|||||||
|
|
||||||
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
|
import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents';
|
||||||
|
|
||||||
|
import { manualLogin, manualSignout } from './fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
||||||
@ -19,17 +21,11 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
|||||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
await manualSignout({ page });
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/signin');
|
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
await page.goto('/signin');
|
await page.waitForURL('/signin');
|
||||||
|
await manualLogin({ page, email: recipient.email, password: recipient.password });
|
||||||
await page.getByLabel('Email').fill(recipient.email);
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
|
||||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/documents');
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
@ -38,10 +34,7 @@ test('[PR-711]: seeded documents should be visible', async ({ page }) => {
|
|||||||
|
|
||||||
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
|
await expect(page.getByRole('link', { name: 'Document 1 - Draft' })).not.toBeVisible();
|
||||||
|
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
await manualSignout({ page });
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/signin');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -74,13 +67,10 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
|
|||||||
|
|
||||||
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
await expect(page.getByRole('row', { name: /Document 1 - Completed/ })).not.toBeVisible();
|
||||||
|
|
||||||
// signout
|
await manualSignout({ page });
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/signin');
|
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
|
await page.waitForURL('/signin');
|
||||||
await page.goto('/signin');
|
await page.goto('/signin');
|
||||||
|
|
||||||
// sign in
|
// sign in
|
||||||
@ -96,11 +86,7 @@ test('[PR-711]: deleting a completed document should not remove it from recipien
|
|||||||
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
|
await expect(page.getByText('Everyone has signed').nth(0)).toBeVisible();
|
||||||
|
|
||||||
await page.goto('/documents');
|
await page.goto('/documents');
|
||||||
|
await manualSignout({ page });
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/signin');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -115,11 +101,7 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
|||||||
|
|
||||||
await page.goto('/signin');
|
await page.goto('/signin');
|
||||||
|
|
||||||
// sign in
|
await manualLogin({ page, email: sender.email, password: sender.password });
|
||||||
await page.getByLabel('Email').fill(sender.email);
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
|
||||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/documents');
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
// open actions menu
|
// open actions menu
|
||||||
@ -133,19 +115,12 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
|||||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||||
|
|
||||||
// signout
|
// signout
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
await manualSignout({ page });
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/signin');
|
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
await page.goto('/signin');
|
await page.waitForURL('/signin');
|
||||||
|
|
||||||
// sign in
|
|
||||||
await page.getByLabel('Email').fill(recipient.email);
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill(recipient.password);
|
|
||||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
|
||||||
|
|
||||||
|
await manualLogin({ page, email: recipient.email, password: recipient.password });
|
||||||
await page.waitForURL('/documents');
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).not.toBeVisible();
|
||||||
@ -154,11 +129,9 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a
|
|||||||
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
await expect(page.getByText(/document.*cancelled/i).nth(0)).toBeVisible();
|
||||||
|
|
||||||
await page.goto('/documents');
|
await page.goto('/documents');
|
||||||
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
await manualSignout({ page });
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/signin');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -167,13 +140,7 @@ test('[PR-711]: deleting a draft document should remove it without additional pr
|
|||||||
}) => {
|
}) => {
|
||||||
const [sender] = TEST_USERS;
|
const [sender] = TEST_USERS;
|
||||||
|
|
||||||
await page.goto('/signin');
|
await manualLogin({ page, email: sender.email, password: sender.password });
|
||||||
|
|
||||||
// sign in
|
|
||||||
await page.getByLabel('Email').fill(sender.email);
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill(sender.password);
|
|
||||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
|
||||||
|
|
||||||
await page.waitForURL('/documents');
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
// open actions menu
|
// open actions menu
|
||||||
|
|||||||
@ -17,12 +17,6 @@ test('[PR-713]: should see sent documents', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').fill('sent');
|
await page.getByPlaceholder('Type a command or search...').fill('sent');
|
||||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// signout
|
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[PR-713]: should see received documents', async ({ page }) => {
|
test('[PR-713]: should see received documents', async ({ page }) => {
|
||||||
@ -40,12 +34,6 @@ test('[PR-713]: should see received documents', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').fill('received');
|
await page.getByPlaceholder('Type a command or search...').fill('received');
|
||||||
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
|
await expect(page.getByRole('option', { name: '[713] Document - Received' })).toBeVisible();
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// signout
|
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
||||||
@ -63,10 +51,4 @@ test('[PR-713]: should be able to search by recipient', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
|
await page.getByPlaceholder('Type a command or search...').fill(recipient.email);
|
||||||
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
await expect(page.getByRole('option', { name: '[713] Document - Sent' })).toBeVisible();
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
|
|
||||||
// signout
|
|
||||||
await page.getByTitle('Profile Dropdown').click();
|
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
|
||||||
});
|
});
|
||||||
|
|||||||
87
packages/app-tests/e2e/teams/manage-team.spec.ts
Normal file
87
packages/app-tests/e2e/teams/manage-team.spec.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { manualLogin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[TEAMS]: create team', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: '/settings/teams',
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamId = `team-${Date.now()}`;
|
||||||
|
|
||||||
|
// Create team.
|
||||||
|
await page.getByRole('button', { name: 'Create team' }).click();
|
||||||
|
await page.getByLabel('Team Name*').fill(teamId);
|
||||||
|
await page.getByTestId('dialog-create-team-button').click();
|
||||||
|
|
||||||
|
await page.getByTestId('dialog-create-team-button').waitFor({ state: 'hidden' });
|
||||||
|
|
||||||
|
const isCheckoutRequired = page.url().includes('pending');
|
||||||
|
test.skip(isCheckoutRequired, 'Test skipped because billing is enabled.');
|
||||||
|
|
||||||
|
// Goto new team settings page.
|
||||||
|
await page.getByRole('row').filter({ hasText: teamId }).getByRole('link').nth(1).click();
|
||||||
|
|
||||||
|
await unseedTeam(teamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: delete team', async ({ page }) => {
|
||||||
|
const team = await seedTeam();
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete team.
|
||||||
|
await page.getByRole('button', { name: 'Delete team' }).click();
|
||||||
|
await page.getByLabel(`Confirm by typing delete ${team.url}`).fill(`delete ${team.url}`);
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
// Check that we have been redirected to the teams page.
|
||||||
|
await page.waitForURL(`${WEBAPP_BASE_URL}/settings/teams`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: update team', async ({ page }) => {
|
||||||
|
const team = await seedTeam();
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to create team page.
|
||||||
|
await page.getByTestId('menu-switcher').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Manage teams' }).click();
|
||||||
|
|
||||||
|
// Goto team settings page.
|
||||||
|
await page.getByRole('row').filter({ hasText: team.url }).getByRole('link').nth(1).click();
|
||||||
|
|
||||||
|
const updatedTeamId = `team-${Date.now()}`;
|
||||||
|
|
||||||
|
// Update team.
|
||||||
|
await page.getByLabel('Team Name*').click();
|
||||||
|
await page.getByLabel('Team Name*').clear();
|
||||||
|
await page.getByLabel('Team Name*').fill(updatedTeamId);
|
||||||
|
await page.getByLabel('Team URL*').click();
|
||||||
|
await page.getByLabel('Team URL*').clear();
|
||||||
|
await page.getByLabel('Team URL*').fill(updatedTeamId);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update team' }).click();
|
||||||
|
|
||||||
|
// Check we have been redirected to the new team URL and the name is updated.
|
||||||
|
await page.waitForURL(`${WEBAPP_BASE_URL}/t/${updatedTeamId}/settings`);
|
||||||
|
|
||||||
|
await unseedTeam(updatedTeamId);
|
||||||
|
});
|
||||||
282
packages/app-tests/e2e/teams/team-documents.spec.ts
Normal file
282
packages/app-tests/e2e/teams/team-documents.spec.ts
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { manualLogin, manualSignout } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
const checkDocumentTabCount = async (page: Page, tabName: string, count: number) => {
|
||||||
|
await page.getByRole('tab', { name: tabName }).click();
|
||||||
|
|
||||||
|
if (tabName !== 'All') {
|
||||||
|
await expect(page.getByRole('tab', { name: tabName })).toContainText(count.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
await expect(page.getByRole('main')).toContainText(`Nothing to do`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByRole('main')).toContainText(`Showing ${count}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('[TEAMS]: check team documents count', async ({ page }) => {
|
||||||
|
const { team, teamMember2 } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
|
||||||
|
for (const user of [team.owner, teamMember2]) {
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 1);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 2);
|
||||||
|
await checkDocumentTabCount(page, 'All', 5);
|
||||||
|
|
||||||
|
// Apply filter.
|
||||||
|
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||||
|
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||||
|
await page.waitForURL(/senderIds/);
|
||||||
|
|
||||||
|
// Check counts after filtering.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
|
await checkDocumentTabCount(page, 'All', 3);
|
||||||
|
|
||||||
|
await manualSignout({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: check team documents count with internal team email', async ({ page }) => {
|
||||||
|
const { team, teamMember2, teamMember4 } = await seedTeamDocuments();
|
||||||
|
const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
const teamEmailMember = teamMember4;
|
||||||
|
|
||||||
|
await seedTeamEmail({
|
||||||
|
email: teamEmailMember.email,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testUser1 = await seedUser();
|
||||||
|
|
||||||
|
await seedDocuments([
|
||||||
|
// Documents sent from the team email account.
|
||||||
|
{
|
||||||
|
sender: teamEmailMember,
|
||||||
|
recipients: [testUser1],
|
||||||
|
type: DocumentStatus.COMPLETED,
|
||||||
|
documentOptions: {
|
||||||
|
teamId: team.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: teamEmailMember,
|
||||||
|
recipients: [testUser1],
|
||||||
|
type: DocumentStatus.PENDING,
|
||||||
|
documentOptions: {
|
||||||
|
teamId: team.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: teamMember4,
|
||||||
|
recipients: [testUser1],
|
||||||
|
type: DocumentStatus.DRAFT,
|
||||||
|
},
|
||||||
|
// Documents sent to the team email account.
|
||||||
|
{
|
||||||
|
sender: testUser1,
|
||||||
|
recipients: [teamEmailMember],
|
||||||
|
type: DocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: testUser1,
|
||||||
|
recipients: [teamEmailMember],
|
||||||
|
type: DocumentStatus.PENDING,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: testUser1,
|
||||||
|
recipients: [teamEmailMember],
|
||||||
|
type: DocumentStatus.DRAFT,
|
||||||
|
},
|
||||||
|
// Document sent to the team email account from another team.
|
||||||
|
{
|
||||||
|
sender: team2Member2,
|
||||||
|
recipients: [teamEmailMember],
|
||||||
|
type: DocumentStatus.PENDING,
|
||||||
|
documentOptions: {
|
||||||
|
teamId: team2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||||
|
for (const user of [team.owner, teamEmailMember]) {
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 3);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 3);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 3);
|
||||||
|
await checkDocumentTabCount(page, 'All', 11);
|
||||||
|
|
||||||
|
// Apply filter.
|
||||||
|
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||||
|
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||||
|
await page.waitForURL(/senderIds/);
|
||||||
|
|
||||||
|
// Check counts after filtering.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
|
await checkDocumentTabCount(page, 'All', 3);
|
||||||
|
|
||||||
|
await manualSignout({ page });
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedTeamEmail({ teamId: team.id });
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: check team documents count with external team email', async ({ page }) => {
|
||||||
|
const { team, teamMember2 } = await seedTeamDocuments();
|
||||||
|
const { team: team2, teamMember2: team2Member2 } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
const teamEmail = `external-team-email-${team.id}@test.documenso.com`;
|
||||||
|
|
||||||
|
await seedTeamEmail({
|
||||||
|
email: teamEmail,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const testUser1 = await seedUser();
|
||||||
|
|
||||||
|
await seedDocuments([
|
||||||
|
// Documents sent to the team email account.
|
||||||
|
{
|
||||||
|
sender: testUser1,
|
||||||
|
recipients: [teamEmail],
|
||||||
|
type: DocumentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: testUser1,
|
||||||
|
recipients: [teamEmail],
|
||||||
|
type: DocumentStatus.PENDING,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: testUser1,
|
||||||
|
recipients: [teamEmail],
|
||||||
|
type: DocumentStatus.DRAFT,
|
||||||
|
},
|
||||||
|
// Document sent to the team email account from another team.
|
||||||
|
{
|
||||||
|
sender: team2Member2,
|
||||||
|
recipients: [teamEmail],
|
||||||
|
type: DocumentStatus.PENDING,
|
||||||
|
documentOptions: {
|
||||||
|
teamId: team2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Document sent to the team email account from an individual user.
|
||||||
|
{
|
||||||
|
sender: testUser1,
|
||||||
|
recipients: [teamEmail],
|
||||||
|
type: DocumentStatus.PENDING,
|
||||||
|
documentOptions: {
|
||||||
|
teamId: team2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sender: testUser1,
|
||||||
|
recipients: [teamEmail],
|
||||||
|
type: DocumentStatus.DRAFT,
|
||||||
|
documentOptions: {
|
||||||
|
teamId: team2.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: teamMember2.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check document counts.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 3);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 2);
|
||||||
|
await checkDocumentTabCount(page, 'All', 9);
|
||||||
|
|
||||||
|
// Apply filter.
|
||||||
|
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
|
||||||
|
await page.getByRole('option', { name: teamMember2.name ?? '' }).click();
|
||||||
|
await page.waitForURL(/senderIds/);
|
||||||
|
|
||||||
|
// Check counts after filtering.
|
||||||
|
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 2);
|
||||||
|
await checkDocumentTabCount(page, 'Completed', 0);
|
||||||
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
|
await checkDocumentTabCount(page, 'All', 3);
|
||||||
|
|
||||||
|
await unseedTeamEmail({ teamId: team.id });
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||||
|
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: currentUser.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await checkDocumentTabCount(page, 'Pending', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||||
|
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: currentUser.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('row').getByRole('button').nth(1).click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Resend' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('test.documenso.com').first().click();
|
||||||
|
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toContainText('Document re-sent');
|
||||||
|
});
|
||||||
102
packages/app-tests/e2e/teams/team-email.spec.ts
Normal file
102
packages/app-tests/e2e/teams/team-email.spec.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { manualLogin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[TEAMS]: send team email request', async ({ page }) => {
|
||||||
|
const team = await seedTeam();
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
password: 'password',
|
||||||
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add email' }).click();
|
||||||
|
await page.getByPlaceholder('eg. Legal').click();
|
||||||
|
await page.getByPlaceholder('eg. Legal').fill('test@test.documenso.com');
|
||||||
|
await page.getByPlaceholder('example@example.com').click();
|
||||||
|
await page.getByPlaceholder('example@example.com').fill('test@test.documenso.com');
|
||||||
|
await page.getByRole('button', { name: 'Add' }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByRole('status')
|
||||||
|
.filter({ hasText: 'We have sent a confirmation email for verification.' })
|
||||||
|
.first(),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: accept team email request', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamEmailVerification = await seedTeamEmailVerification({
|
||||||
|
email: 'team-email-verification@test.documenso.com',
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/team/verify/email/${teamEmailVerification.token}`);
|
||||||
|
await expect(page.getByRole('heading')).toContainText('Team email verified!');
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: delete team email', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
createTeamEmail: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Remove' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Team email has been removed').first()).toBeVisible();
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: team email owner removes access', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
createTeamEmail: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team.teamEmail) {
|
||||||
|
throw new Error('Not possible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamEmailOwner = await seedUser({
|
||||||
|
email: team.teamEmail.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: teamEmailOwner.email,
|
||||||
|
redirectPath: `/settings/teams`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Revoke access' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Revoke' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('You have successfully revoked').first()).toBeVisible();
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
await unseedUser(teamEmailOwner.id);
|
||||||
|
});
|
||||||
110
packages/app-tests/e2e/teams/team-members.spec.ts
Normal file
110
packages/app-tests/e2e/teams/team-members.spec.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { manualLogin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[TEAMS]: update team member role', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
password: 'password',
|
||||||
|
redirectPath: `/t/${team.url}/settings/members`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMemberToUpdate = team.members[1];
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('row')
|
||||||
|
.filter({ hasText: teamMemberToUpdate.user.email })
|
||||||
|
.getByRole('button')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: 'Update role' }).click();
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel('Manager').click();
|
||||||
|
await page.getByRole('button', { name: 'Update' }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('row').filter({ hasText: teamMemberToUpdate.user.email }),
|
||||||
|
).toContainText('Manager');
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: accept team invitation without account', async ({ page }) => {
|
||||||
|
const team = await seedTeam();
|
||||||
|
|
||||||
|
const teamInvite = await seedTeamInvite({
|
||||||
|
email: `team-invite-test-${Date.now()}@test.documenso.com`,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
|
||||||
|
await expect(page.getByRole('heading')).toContainText('Team invitation');
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: accept team invitation with account', async ({ page }) => {
|
||||||
|
const team = await seedTeam();
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const teamInvite = await seedTeamInvite({
|
||||||
|
email: user.email,
|
||||||
|
teamId: team.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/team/invite/${teamInvite.token}`);
|
||||||
|
await expect(page.getByRole('heading')).toContainText('Invitation accepted!');
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: member can leave team', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMember = team.members[1];
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: teamMember.user.email,
|
||||||
|
password: 'password',
|
||||||
|
redirectPath: `/settings/teams`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Leave' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Leave' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status').first()).toContainText(
|
||||||
|
'You have successfully left this team.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[TEAMS]: owner cannot leave team', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
password: 'password',
|
||||||
|
redirectPath: `/settings/teams`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('button').getByText('Leave')).toBeDisabled();
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
69
packages/app-tests/e2e/teams/transfer-team.spec.ts
Normal file
69
packages/app-tests/e2e/teams/transfer-team.spec.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
|
||||||
|
import { manualLogin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamMember = team.members[1];
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: team.owner.email,
|
||||||
|
password: 'password',
|
||||||
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Transfer team' }).click();
|
||||||
|
|
||||||
|
await page.getByRole('combobox').click();
|
||||||
|
await page.getByLabel(teamMember.user.name ?? '').click();
|
||||||
|
await page.getByLabel('Confirm by typing transfer').click();
|
||||||
|
await page.getByLabel('Confirm by typing transfer').fill('transfer');
|
||||||
|
await page.getByRole('button', { name: 'Transfer' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('[id="\\:r2\\:-form-item-message"]')).toContainText(
|
||||||
|
`You must enter 'transfer ${team.name}' to proceed`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByLabel('Confirm by typing transfer').click();
|
||||||
|
await page.getByLabel('Confirm by typing transfer').fill(`transfer ${team.name}`);
|
||||||
|
await page.getByRole('button', { name: 'Transfer' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Team transfer in progress' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status').first()).toContainText(
|
||||||
|
'The team transfer invitation has been successfully deleted.',
|
||||||
|
);
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current skipped until we disable billing during tests.
|
||||||
|
*/
|
||||||
|
test.skip('[TEAMS]: accept team transfer', async ({ page }) => {
|
||||||
|
const team = await seedTeam({
|
||||||
|
createTeamMembers: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOwnerMember = team.members[1];
|
||||||
|
|
||||||
|
const teamTransferRequest = await seedTeamTransfer({
|
||||||
|
teamId: team.id,
|
||||||
|
newOwnerUserId: newOwnerMember.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/team/verify/transfer/${teamTransferRequest.token}`);
|
||||||
|
await expect(page.getByRole('heading')).toContainText('Team ownership transferred!');
|
||||||
|
|
||||||
|
await unseedTeam(team.url);
|
||||||
|
});
|
||||||
@ -30,7 +30,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page }
|
|||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Sign Up' }).click();
|
await page.getByRole('button', { name: 'Sign Up', exact: true }).click();
|
||||||
await page.waitForURL('/documents');
|
await page.waitForURL('/documents');
|
||||||
|
|
||||||
await expect(page).toHaveURL('/documents');
|
await expect(page).toHaveURL('/documents');
|
||||||
|
|||||||
@ -1,17 +1,23 @@
|
|||||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
import { FREE_PLAN_LIMITS } from './constants';
|
import { FREE_PLAN_LIMITS } from './constants';
|
||||||
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
|
import type { TLimitsResponseSchema } from './schema';
|
||||||
|
import { ZLimitsResponseSchema } from './schema';
|
||||||
|
|
||||||
export type GetLimitsOptions = {
|
export type GetLimitsOptions = {
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
teamId?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
|
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
|
||||||
const requestHeaders = headers ?? {};
|
const requestHeaders = headers ?? {};
|
||||||
|
|
||||||
const url = new URL(`${APP_BASE_URL}/api/limits`);
|
const url = new URL(`${APP_BASE_URL}/api/limits`);
|
||||||
|
|
||||||
|
if (teamId) {
|
||||||
|
requestHeaders['team-id'] = teamId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
...requestHeaders,
|
...requestHeaders,
|
||||||
|
|||||||
@ -1,10 +1,15 @@
|
|||||||
import { TLimitsSchema } from './schema';
|
import type { TLimitsSchema } from './schema';
|
||||||
|
|
||||||
export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
||||||
documents: 5,
|
documents: 5,
|
||||||
recipients: 10,
|
recipients: 10,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TEAM_PLAN_LIMITS: TLimitsSchema = {
|
||||||
|
documents: Infinity,
|
||||||
|
recipients: Infinity,
|
||||||
|
};
|
||||||
|
|
||||||
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||||
documents: Infinity,
|
documents: Infinity,
|
||||||
recipients: Infinity,
|
recipients: Infinity,
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import { getToken } from 'next-auth/jwt';
|
import { getToken } from 'next-auth/jwt';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { ERROR_CODES } from './errors';
|
import { ERROR_CODES } from './errors';
|
||||||
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
import type { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||||
import { getServerLimits } from './server';
|
import { getServerLimits } from './server';
|
||||||
|
|
||||||
export const limitsHandler = async (
|
export const limitsHandler = async (
|
||||||
@ -14,7 +14,19 @@ export const limitsHandler = async (
|
|||||||
try {
|
try {
|
||||||
const token = await getToken({ req });
|
const token = await getToken({ req });
|
||||||
|
|
||||||
const limits = await getServerLimits({ email: token?.email });
|
const rawTeamId = req.headers['team-id'];
|
||||||
|
|
||||||
|
let teamId: number | null = null;
|
||||||
|
|
||||||
|
if (typeof rawTeamId === 'string' && !isNaN(parseInt(rawTeamId, 10))) {
|
||||||
|
teamId = parseInt(rawTeamId, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teamId && rawTeamId) {
|
||||||
|
throw new Error(ERROR_CODES.INVALID_TEAM_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limits = await getServerLimits({ email: token?.email, teamId });
|
||||||
|
|
||||||
return res.status(200).json(limits);
|
return res.status(200).json(limits);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { equals } from 'remeda';
|
|||||||
|
|
||||||
import { getLimits } from '../client';
|
import { getLimits } from '../client';
|
||||||
import { FREE_PLAN_LIMITS } from '../constants';
|
import { FREE_PLAN_LIMITS } from '../constants';
|
||||||
import { TLimitsResponseSchema } from '../schema';
|
import type { TLimitsResponseSchema } from '../schema';
|
||||||
|
|
||||||
export type LimitsContextValue = TLimitsResponseSchema;
|
export type LimitsContextValue = TLimitsResponseSchema;
|
||||||
|
|
||||||
@ -24,19 +24,22 @@ export const useLimits = () => {
|
|||||||
|
|
||||||
export type LimitsProviderProps = {
|
export type LimitsProviderProps = {
|
||||||
initialValue?: LimitsContextValue;
|
initialValue?: LimitsContextValue;
|
||||||
|
teamId?: number;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
|
export const LimitsProvider = ({
|
||||||
const defaultValue: TLimitsResponseSchema = {
|
initialValue = {
|
||||||
quota: FREE_PLAN_LIMITS,
|
quota: FREE_PLAN_LIMITS,
|
||||||
remaining: FREE_PLAN_LIMITS,
|
remaining: FREE_PLAN_LIMITS,
|
||||||
};
|
},
|
||||||
|
teamId,
|
||||||
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
|
children,
|
||||||
|
}: LimitsProviderProps) => {
|
||||||
|
const [limits, setLimits] = useState(() => initialValue);
|
||||||
|
|
||||||
const refreshLimits = async () => {
|
const refreshLimits = async () => {
|
||||||
const newLimits = await getLimits();
|
const newLimits = await getLimits({ teamId });
|
||||||
|
|
||||||
setLimits((oldLimits) => {
|
setLimits((oldLimits) => {
|
||||||
if (equals(oldLimits, newLimits)) {
|
if (equals(oldLimits, newLimits)) {
|
||||||
|
|||||||
@ -3,16 +3,22 @@
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
import { getLimits } from '../client';
|
import { getLimits } from '../client';
|
||||||
|
import type { LimitsContextValue } from './client';
|
||||||
import { LimitsProvider as ClientLimitsProvider } from './client';
|
import { LimitsProvider as ClientLimitsProvider } from './client';
|
||||||
|
|
||||||
export type LimitsProviderProps = {
|
export type LimitsProviderProps = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
teamId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
|
export const LimitsProvider = async ({ children, teamId }: LimitsProviderProps) => {
|
||||||
const requestHeaders = Object.fromEntries(headers().entries());
|
const requestHeaders = Object.fromEntries(headers().entries());
|
||||||
|
|
||||||
const limits = await getLimits({ headers: requestHeaders });
|
const limits: LimitsContextValue = await getLimits({ headers: requestHeaders, teamId });
|
||||||
|
|
||||||
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
|
return (
|
||||||
|
<ClientLimitsProvider initialValue={limits} teamId={teamId}>
|
||||||
|
{children}
|
||||||
|
</ClientLimitsProvider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { getPricesByType } from '../stripe/get-prices-by-type';
|
import { getPricesByPlan } from '../stripe/get-prices-by-plan';
|
||||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
|
||||||
import { ERROR_CODES } from './errors';
|
import { ERROR_CODES } from './errors';
|
||||||
import { ZLimitsSchema } from './schema';
|
import { ZLimitsSchema } from './schema';
|
||||||
|
|
||||||
export type GetServerLimitsOptions = {
|
export type GetServerLimitsOptions = {
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
teamId?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
|
||||||
const isBillingEnabled = await getFlag('app_billing');
|
if (!IS_BILLING_ENABLED) {
|
||||||
|
|
||||||
if (!isBillingEnabled) {
|
|
||||||
return {
|
return {
|
||||||
quota: SELFHOSTED_PLAN_LIMITS,
|
quota: SELFHOSTED_PLAN_LIMITS,
|
||||||
remaining: SELFHOSTED_PLAN_LIMITS,
|
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||||
@ -27,6 +27,14 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
|||||||
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return teamId ? handleTeamLimits({ email, teamId }) : handleUserLimits({ email });
|
||||||
|
};
|
||||||
|
|
||||||
|
type HandleUserLimitsOptions = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email,
|
email,
|
||||||
@ -48,10 +56,10 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (activeSubscriptions.length > 0) {
|
if (activeSubscriptions.length > 0) {
|
||||||
const individualPrices = await getPricesByType('individual');
|
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
|
||||||
|
|
||||||
for (const subscription of activeSubscriptions) {
|
for (const subscription of activeSubscriptions) {
|
||||||
const price = individualPrices.find((price) => price.id === subscription.priceId);
|
const price = communityPlanPrices.find((price) => price.id === subscription.priceId);
|
||||||
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -71,6 +79,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
|||||||
const documents = await prisma.document.count({
|
const documents = await prisma.document.count({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
teamId: null,
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||||
},
|
},
|
||||||
@ -84,3 +93,50 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
|||||||
remaining,
|
remaining,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HandleTeamLimitsOptions = {
|
||||||
|
email: string;
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTeamLimits = async ({ email, teamId }: HandleTeamLimitsOptions) => {
|
||||||
|
const team = await prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: teamId,
|
||||||
|
members: {
|
||||||
|
some: {
|
||||||
|
user: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
subscription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new Error('Team not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { subscription } = team;
|
||||||
|
|
||||||
|
if (subscription && subscription.status === SubscriptionStatus.INACTIVE) {
|
||||||
|
return {
|
||||||
|
quota: {
|
||||||
|
documents: 0,
|
||||||
|
recipients: 0,
|
||||||
|
},
|
||||||
|
remaining: {
|
||||||
|
documents: 0,
|
||||||
|
recipients: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
quota: structuredClone(TEAM_PLAN_LIMITS),
|
||||||
|
remaining: structuredClone(TEAM_PLAN_LIMITS),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
20
packages/ee/server-only/stripe/create-team-customer.ts
Normal file
20
packages/ee/server-only/stripe/create-team-customer.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing';
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
|
||||||
|
type CreateTeamCustomerOptions = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe customer for a given team.
|
||||||
|
*/
|
||||||
|
export const createTeamCustomer = async ({ name, email }: CreateTeamCustomerOptions) => {
|
||||||
|
return await stripe.customers.create({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
metadata: {
|
||||||
|
type: STRIPE_CUSTOMER_TYPE.TEAM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user