diff --git a/apps/web/public/static/add-user.png b/apps/web/public/static/add-user.png new file mode 100644 index 000000000..abd337ceb Binary files /dev/null and b/apps/web/public/static/add-user.png differ diff --git a/apps/web/public/static/mail-open-alert.png b/apps/web/public/static/mail-open-alert.png new file mode 100644 index 000000000..1511f0bc5 Binary files /dev/null and b/apps/web/public/static/mail-open-alert.png differ diff --git a/apps/web/public/static/mail-open.png b/apps/web/public/static/mail-open.png new file mode 100644 index 000000000..306313b03 Binary files /dev/null and b/apps/web/public/static/mail-open.png differ diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx index 83ad81ca1..0fc660968 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -7,9 +7,9 @@ import Link from 'next/link'; import { Loader } from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { FindResultSet } from '@documenso/lib/types/find-result-set'; -import { recipientInitials } from '@documenso/lib/utils/recipient-formatter'; -import { Document, User } from '@documenso/prisma/client'; +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import type { Document, User } from '@documenso/prisma/client'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; @@ -65,7 +65,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { accessorKey: 'owner', cell: ({ row }) => { const avatarFallbackText = row.original.User.name - ? recipientInitials(row.original.User.name) + ? extractInitials(row.original.User.name) : row.original.User.email.slice(0, 1).toUpperCase(); return ( diff --git a/packages/ui/primitives/multiselect-combobox.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx similarity index 95% rename from packages/ui/primitives/multiselect-combobox.tsx rename to apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx index bac87ce0b..9a25af897 100644 --- a/packages/ui/primitives/multiselect-combobox.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/multiselect-role-combobox.tsx @@ -19,7 +19,7 @@ type ComboboxProps = { onChange: (_values: string[]) => void; }; -const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { +const MultiSelectRoleCombobox = ({ listValues, onChange }: ComboboxProps) => { const [open, setOpen] = React.useState(false); const [selectedValues, setSelectedValues] = React.useState([]); const dbRoles = Object.values(Role); @@ -79,4 +79,4 @@ const MultiSelectCombobox = ({ listValues, onChange }: ComboboxProps) => { ); }; -export { MultiSelectCombobox }; +export { MultiSelectRoleCombobox }; diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 9ae270d28..3bd909623 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -18,9 +18,10 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { MultiSelectRoleCombobox } from './multiselect-role-combobox'; + const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); type TUserFormSchema = z.infer; @@ -117,7 +118,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
Roles - onChange(values)} /> diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 069378274..577e0739a 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -1,4 +1,5 @@ -import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; +import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan'; +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { UsersDataTable } from './data-table-users'; import { search } from './fetch-users.actions'; @@ -18,7 +19,7 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag const [{ users, totalPages }, individualPrices] = await Promise.all([ search(searchString, page, perPage), - getPricesByType('individual'), + getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), ]); const individualPriceIds = individualPrices.map((price) => price.id); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx new file mode 100644 index 000000000..3a46ed5e7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/[id]/document-page-view.tsx @@ -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 ( +
+ + + Documents + + +

+ {document.title} +

+ +
+ + + {recipients.length > 0 && ( +
+ + + + {recipients.length} Recipient(s) + +
+ )} +
+ + {document.status !== InternalDocumentStatus.COMPLETED && ( + + )} + + {document.status === InternalDocumentStatus.COMPLETED && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index af1877a64..e6cbd6fd4 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -32,6 +32,7 @@ export type EditDocumentFormProps = { documentMeta: DocumentMeta | null; fields: Field[]; documentData: DocumentData; + documentRootPath: string; }; type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject'; @@ -45,6 +46,7 @@ export const EditDocumentForm = ({ documentMeta, user: _user, documentData, + documentRootPath, }: EditDocumentFormProps) => { const { toast } = useToast(); const router = useRouter(); @@ -168,7 +170,7 @@ export const EditDocumentForm = ({ duration: 5000, }); - router.push('/documents'); + router.push(documentRootPath); } catch (err) { console.error(err); diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 44f3991d8..e7a34889e 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -1,20 +1,4 @@ -import Link from 'next/link'; -import { redirect } from 'next/navigation'; - -import { ChevronLeft, Users2 } from 'lucide-react'; - -import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; -import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; -import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; -import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; -import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; -import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; -import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; - -import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; -import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; -import { DocumentStatus } from '~/components/formatter/document-status'; +import DocumentPageView from './document-page-view'; export type DocumentPageProps = { params: { @@ -22,103 +6,6 @@ export type DocumentPageProps = { }; }; -export default async function DocumentPage({ params }: DocumentPageProps) { - const { id } = params; - - const documentId = Number(id); - - if (!documentId || Number.isNaN(documentId)) { - redirect('/documents'); - } - - const { user } = await getRequiredServerComponentSession(); - - const document = await getDocumentById({ - id: documentId, - userId: user.id, - }).catch(() => null); - - if (!document || !document.documentData) { - redirect('/documents'); - } - - const { documentData, documentMeta } = document; - - if (documentMeta?.password) { - const key = DOCUMENSO_ENCRYPTION_KEY; - - if (!key) { - throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); - } - - const securePassword = Buffer.from( - symmetricDecrypt({ - key, - data: documentMeta.password, - }), - ).toString('utf-8'); - - documentMeta.password = securePassword; - } - - const [recipients, fields] = await Promise.all([ - getRecipientsForDocument({ - documentId, - userId: user.id, - }), - getFieldsForDocument({ - documentId, - userId: user.id, - }), - ]); - - return ( -
- - - Documents - - -

- {document.title} -

- -
- - - {recipients.length > 0 && ( -
- - - - {recipients.length} Recipient(s) - -
- )} -
- - {document.status !== InternalDocumentStatus.COMPLETED && ( - - )} - - {document.status === InternalDocumentStatus.COMPLETED && ( -
- -
- )} -
- ); +export default function DocumentPage({ params }: DocumentPageProps) { + return ; } diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx index 7fabeef95..e8e3d6130 100644 --- a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx @@ -10,6 +10,7 @@ import * as z from 'zod'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; +import type { Team } from '@documenso/prisma/client'; import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client'; import { trpc as trpcReact } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -39,8 +40,11 @@ import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar'; const FORM_ID = 'resend-email'; export type ResendDocumentActionItemProps = { - document: Document; + document: Document & { + team: Pick | null; + }; recipients: Recipient[]; + team?: Pick; }; export const ZResendDocumentFormSchema = z.object({ @@ -54,15 +58,17 @@ export type TResendDocumentFormSchema = z.infer { const { data: session } = useSession(); const { toast } = useToast(); const [isOpen, setIsOpen] = useState(false); const isOwner = document.userId === session?.user?.id; + const isCurrentTeamDocument = team && document.team?.url === team.url; const isDisabled = - !isOwner || + (!isOwner && !isCurrentTeamDocument) || document.status !== 'PENDING' || !recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED); @@ -82,7 +88,7 @@ export const ResendDocumentActionItem = ({ const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => { try { - await resendDocument({ documentId: document.id, recipients }); + await resendDocument({ documentId: document.id, recipients, teamId: team?.id }); toast({ title: 'Document re-sent', diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index ecddf1190..78ffd0b3b 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -7,7 +7,8 @@ import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; -import type { Document, Recipient, User } from '@documenso/prisma/client'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; +import type { Document, Recipient, Team, User } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; @@ -18,10 +19,12 @@ export type DataTableActionButtonProps = { row: Document & { User: Pick; Recipient: Recipient[]; + team: Pick | null; }; + team?: Pick; }; -export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { +export const DataTableActionButton = ({ row, team }: DataTableActionButtonProps) => { const { data: session } = useSession(); const { toast } = useToast(); @@ -38,6 +41,9 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isComplete = row.status === DocumentStatus.COMPLETED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const role = recipient?.role; + const isCurrentTeamDocument = team && row.team?.url === team.url; + + const documentsPath = formatDocumentsPath(team?.url); const onDownloadClick = async () => { try { @@ -46,6 +52,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { if (!recipient) { document = await trpcClient.document.getDocumentById.query({ id: row.id, + teamId: team?.id, }); } else { document = await trpcClient.document.getDocumentByToken.query({ @@ -81,15 +88,19 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { isPending, isComplete, isSigned, + isCurrentTeamDocument, }) - .with({ isOwner: true, isDraft: true }, () => ( - - )) + .with( + isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true }, + () => ( + + ), + ) .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( ); diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index e226a7e39..cee2aa2f1 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -5,8 +5,9 @@ import { match } from 'ts-pattern'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; -import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type'; +import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan'; import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { type Stripe } from '@documenso/lib/server-only/stripe'; @@ -36,23 +37,23 @@ export default async function BillingSettingsPage() { user = await getStripeCustomerByUser(user).then((result) => result.user); } - const [subscriptions, prices, individualPrices] = await Promise.all([ + const [subscriptions, prices, communityPlanPrices] = await Promise.all([ getSubscriptionsByUserId({ userId: user.id }), - getPricesByInterval({ type: 'individual' }), - getPricesByType('individual'), + getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }), + getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY), ]); - const individualPriceIds = individualPrices.map(({ id }) => id); + const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id); let subscriptionProduct: Stripe.Product | null = null; - const individualUserSubscriptions = subscriptions.filter(({ priceId }) => - individualPriceIds.includes(priceId), + const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) => + communityPlanPriceIds.includes(priceId), ); const subscription = - individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? - individualUserSubscriptions[0]; + communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? + communityPlanUserSubscriptions[0]; if (subscription?.priceId) { subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index 60f7da49c..2890eb5d5 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { ProfileForm } from '~/components/forms/profile'; export const metadata: Metadata = { @@ -13,11 +14,7 @@ export default async function ProfileSettingsPage() { return (
-

Profile

- -

Here you can edit your personal details.

- -
+
diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index 4e0a40838..f46784aed 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -6,6 +6,7 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get- import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes'; import { PasswordForm } from '~/components/forms/password'; @@ -19,13 +20,10 @@ export default async function SecuritySettingsPage() { return (
-

Security

- -

- Here you can manage your password and security settings. -

- -
+ {user.identityProvider === 'DOCUMENSO' ? (
diff --git a/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx new file mode 100644 index 000000000..8aa81653d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/accept-team-invitation-button.tsx @@ -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 ( + + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/teams/page.tsx b/apps/web/src/app/(dashboard)/settings/teams/page.tsx new file mode 100644 index 000000000..1a3d90b66 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/page.tsx @@ -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 ( +
+ + + + + + +
+ + {teamEmail && ( + + + + )} + + + +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx new file mode 100644 index 000000000..56a7b110a --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/team-email-usage.tsx @@ -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 ( + +
+ Team Email + +

+ Your email is currently being used by team{' '} + {teamEmail.team.name} ({teamEmail.team.url} + ). +

+ +

They have permission on your behalf to:

+ +
    +
  • Display your name and email in documents
  • +
  • View all documents sent to your account
  • +
+
+
+ + !isDeletingTeamEmail && setOpen(value)}> + + + + + + + Are you sure? + + + You are about to revoke access for team{' '} + {teamEmail.team.name} ({teamEmail.team.url}) to + use your email. + + + +
+ + + + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx new file mode 100644 index 000000000..aa1be3f3f --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/teams/team-invitations.tsx @@ -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 ( + + {data && data.length > 0 && !isInitialLoading && ( + + +
+ + + + You have {data.length} pending team invitation + {data.length > 1 ? 's' : ''}. + + + + + + + + + + Pending invitations + + + You have {data.length} pending team invitation{data.length > 1 ? 's' : ''}. + + + +
    + {data.map((invitation) => ( +
  • + + {invitation.team.name} + + } + secondaryText={formatTeamUrl(invitation.team.url)} + rightSideComponent={ +
    + +
    + } + /> +
  • + ))} +
+
+
+
+
+
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 7930dcd0e..0e8f822c2 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -83,7 +83,7 @@ export const TemplatesDataTable = ({ return (
{remaining.documents === 0 && ( - + Document Limit Exceeded! diff --git a/apps/web/src/app/(signing)/sign/[token]/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/layout.tsx index cfec41cdf..9db36e8aa 100644 --- a/apps/web/src/app/(signing)/sign/[token]/layout.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/layout.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header'; import { NextAuthProvider } from '~/providers/next-auth'; @@ -12,10 +14,16 @@ export type SigningLayoutProps = { export default async function SigningLayout({ children }: SigningLayoutProps) { const { user, session } = await getServerComponentSession(); + let teams: GetTeamsResponse = []; + + if (user && session) { + teams = await getTeams({ userId: user.id }); + } + return (
- {user && } + {user && }
{children}
diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx new file mode 100644 index 000000000..b7f610cff --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/[id]/page.tsx @@ -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 ; +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx new file mode 100644 index 000000000..952aeeeea --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/documents/page.tsx @@ -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 ; +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx new file mode 100644 index 000000000..1e1eb9921 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/error.tsx @@ -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 ( +
+
+

{errorMessage}

+ +

Oops! Something went wrong.

+ +

{errorDetails}

+ +
+ + + +
+
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx new file mode 100644 index 000000000..3b4f43031 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout-billing-banner.tsx @@ -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 ( + <> +
+
+
+ + + {match(subscription.status) + .with(SubscriptionStatus.PAST_DUE, () => 'Payment overdue') + .with(SubscriptionStatus.INACTIVE, () => 'Teams restricted') + .exhaustive()} +
+ + +
+
+ + !isLoading && setIsOpen(value)}> + + Payment overdue + + {match(subscription.status) + .with(SubscriptionStatus.PAST_DUE, () => ( + + Your payment for teams is overdue. Please settle the payment to avoid any service + disruptions. + + )) + .with(SubscriptionStatus.INACTIVE, () => ( + + Due to an unpaid invoice, your team has been restricted. Please settle the payment + to restore full access to your team. + + )) + .otherwise(() => null)} + + {canExecuteTeamAction('MANAGE_BILLING', userRole) && ( + + + + )} + + + + ); +}; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx new file mode 100644 index 000000000..2883abc21 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/layout.tsx @@ -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 ( + + + {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && ( + + )} + +
+ +
{children}
+ + + + + ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx new file mode 100644 index 000000000..35962e264 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/not-found.tsx @@ -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 ( +
+
+

404 Team not found

+ +

Oops! Something went wrong.

+ +

+ The team you are looking for may have been removed, renamed or may have never existed. +

+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx new file mode 100644 index 000000000..1d0e87f79 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/billing/page.tsx @@ -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 ( +
+ + + + +
+

+ Current plan: {teamSubscription ? 'Team' : 'Community Team'} +

+ +

+ {formatTeamSubscriptionDetails(teamSubscription)} +

+
+ + {teamSubscription && ( +
+ +
+ )} +
+
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx new file mode 100644 index 000000000..fe2ee5aee --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/layout.tsx @@ -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 ( +
+

Team Settings

+ +
+ + + +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx new file mode 100644 index 000000000..4617b3d48 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/members/page.tsx @@ -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 ( +
+ + + + + +
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx new file mode 100644 index 000000000..a86797191 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/page.tsx @@ -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 ( +
+ + + + + + +
+ {(team.teamEmail || team.emailVerification) && ( + + Team email + + + You can view documents associated with this email and use this identity when sending + documents. + + +
+ +
+ + {team.teamEmail?.name || team.emailVerification?.name} + + } + secondaryText={ + + {team.teamEmail?.email || team.emailVerification?.email} + + } + /> + +
+
+ {match({ + teamEmail: team.teamEmail, + emailVerification: team.emailVerification, + }) + .with({ teamEmail: P.not(null) }, () => ( + <> + + Active + + )) + .with( + { + emailVerification: P.when( + (emailVerification) => + emailVerification && emailVerification?.expiresAt < new Date(), + ), + }, + () => ( + <> + + Expired + + ), + ) + .with({ emailVerification: P.not(null) }, () => ( + <> + + Awaiting email confirmation + + )) + .otherwise(() => null)} +
+ + +
+
+
+ )} + + {!team.teamEmail && !team.emailVerification && ( + +
+ Team email + + +
    + {/* Feature not available yet. */} + {/*
  • Display this name and email when sending documents
  • */} + {/*
  • View documents associated with this email
  • */} + + View documents associated with this email +
+
+
+ + +
+ )} + + {team.ownerUserId === session.user.id && ( + <> + {isTransferVerificationExpired && ( + +
+ Transfer team + + + Transfer the ownership of the team to another team member. + +
+ + +
+ )} + + +
+ Delete team + + + This team, and any associated data excluding billing invoices will be permanently + deleted. + +
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx new file mode 100644 index 000000000..e2c0a0d87 --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-email-dropdown.tsx @@ -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>; +}; + +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 ( + + + + + + + {!team.teamEmail && team.emailVerification && ( + { + e.preventDefault(); + void resendEmailVerification({ teamId: team.id }); + }} + > + {isResendingEmailVerification ? ( + + ) : ( + + )} + Resend verification + + )} + + {team.teamEmail && ( + e.preventDefault()}> + + Edit + + } + /> + )} + + onRemove()} + > + + Remove + + + + ); +}; diff --git a/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx new file mode 100644 index 000000000..cba50966f --- /dev/null +++ b/apps/web/src/app/(teams)/t/[teamUrl]/settings/team-transfer-status.tsx @@ -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 ( + + {transferVerification && ( + + +
+ + {isExpired ? 'Team transfer request expired' : 'Team transfer in progress'} + + + + {isExpired ? ( +

+ The team transfer request to {transferVerification.name} has + expired. +

+ ) : ( +
+

+ A request to transfer the ownership of this team has been sent to{' '} + + {transferVerification.name} ({transferVerification.email}) + +

+ +

+ If they accept this request, the team will be transferred to their account. +

+
+ )} +
+
+ + {canExecuteTeamAction('DELETE_TEAM_TRANSFER_REQUEST', currentUserTeamRole) && ( + + )} +
+
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 1332a3f37..8331e7c03 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from 'next'; import Link from 'next/link'; +import { redirect } from 'next/navigation'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { SignInForm } from '~/components/forms/signin'; @@ -9,7 +11,20 @@ export const metadata: Metadata = { 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 (

Sign in to your account

@@ -18,7 +33,11 @@ export default function SignInPage() { Welcome back, we are lucky to have you.

- + {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (

diff --git a/apps/web/src/app/(unauthenticated)/signup/page.tsx b/apps/web/src/app/(unauthenticated)/signup/page.tsx index c6d49f891..dbbbcdba9 100644 --- a/apps/web/src/app/(unauthenticated)/signup/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signup/page.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; +import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { SignUpForm } from '~/components/forms/signup'; @@ -10,11 +11,24 @@ export const metadata: Metadata = { 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') { redirect('/signin'); } + const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined; + const email = rawEmail ? decryptSecondaryData(rawEmail) : null; + + if (!email && rawEmail) { + redirect('/signup'); + } + return (

Create a new account

@@ -24,7 +38,11 @@ export default function SignUpPage() { signing is within your grasp.

- +

Already have an account?{' '} diff --git a/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx new file mode 100644 index 000000000..634416fe3 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/invite/[token]/page.tsx @@ -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 ( +

+

Invalid token

+ +

+ This token is invalid or has expired. Please contact your team for a new invitation. +

+ + +
+ ); + } + + 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 ( +
+

Team invitation

+ +

+ You have been invited by {team.name} to join their team. +

+ +

+ To accept this invitation you must create an account. +

+ + +
+ ); + } + + const isSessionUserTheInvitedUser = user.id === session.user?.id; + + return ( +
+

Invitation accepted!

+ +

+ You have accepted an invitation from {team.name} to join their team. +

+ + {isSessionUserTheInvitedUser ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx new file mode 100644 index 000000000..53ad4461b --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/verify/email/[token]/page.tsx @@ -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 ( +
+

Invalid link

+ +

+ This link is invalid or has expired. Please contact your team to resend a verification. +

+ + +
+ ); + } + + 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 ( +
+

Team email verification

+ +

+ Something went wrong while attempting to verify your email address for{' '} + {team.name}. Please try again later. +

+
+ ); + } + + return ( +
+

Team email verified!

+ +

+ You have verified your email address for {team.name}. +

+ + +
+ ); +} diff --git a/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx new file mode 100644 index 000000000..819b7e970 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/team/verify/transfer/[token]/page.tsx @@ -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 ( +
+

Invalid link

+ +

+ This link is invalid or has expired. Please contact your team to resend a transfer + request. +

+ + +
+ ); + } + + const { team } = teamTransferVerification; + + let isTransferError = false; + + try { + await transferTeamOwnership({ token }); + } catch (e) { + console.error(e); + isTransferError = true; + } + + if (isTransferError) { + return ( +
+

Team ownership transfer

+ +

+ Something went wrong while attempting to transfer the ownership of team{' '} + {team.name} to your. Please try again later or contact support. +

+
+ ); + } + + return ( +
+

Team ownership transferred!

+ +

+ The ownership of team {team.name} has been successfully transferred to you. +

+ + +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx index 0312a96d2..3fe42a4c4 100644 --- a/apps/web/src/components/(dashboard)/common/command-menu.tsx +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -197,20 +197,22 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { )} {!currentPage && ( <> - + - + - + - - addPage('theme')}>Change theme + + addPage('theme')}> + Change theme + {searchResults.length > 0 && ( - + )} @@ -231,6 +233,7 @@ const Commands = ({ }) => { return pages.map((page, idx) => ( push(page.path)} @@ -255,7 +258,7 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => 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.label} diff --git a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx index e04bc2818..2b11c4be2 100644 --- a/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/layout/desktop-nav.tsx @@ -4,10 +4,11 @@ import type { HTMLAttributes } from 'react'; import { useEffect, useState } from 'react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { useParams, usePathname } from 'next/navigation'; import { Search } from 'lucide-react'; +import { getRootHref } from '@documenso/lib/utils/params'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -28,10 +29,13 @@ export type DesktopNavProps = HTMLAttributes; export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { const pathname = usePathname(); + const params = useParams(); const [open, setOpen] = useState(false); const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); + const rootHref = getRootHref(params, { returnEmptyRootString: true }); + useEffect(() => { const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown'; const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent); @@ -48,20 +52,24 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { {...props} >
- {navigationLinks.map(({ href, label }) => ( - - {label} - - ))} + {navigationLinks + .filter(({ href }) => href !== '/templates' || rootHref === '') // Remove templates for team pages. + .map(({ href, label }) => ( + + {label} + + ))}
diff --git a/apps/web/src/components/(dashboard)/layout/header.tsx b/apps/web/src/components/(dashboard)/layout/header.tsx index ba35671e6..753f5fb11 100644 --- a/apps/web/src/components/(dashboard)/layout/header.tsx +++ b/apps/web/src/components/(dashboard)/layout/header.tsx @@ -1,23 +1,34 @@ 'use client'; -import type { HTMLAttributes } from 'react'; -import { useEffect, useState } from 'react'; +import { type HTMLAttributes, useEffect, useState } from 'react'; import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { MenuIcon, SearchIcon } from 'lucide-react'; + +import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams'; +import { getRootHref } from '@documenso/lib/utils/params'; import type { User } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; import { Logo } from '~/components/branding/logo'; +import { CommandMenu } from '../common/command-menu'; import { DesktopNav } from './desktop-nav'; -import { ProfileDropdown } from './profile-dropdown'; +import { MenuSwitcher } from './menu-switcher'; +import { MobileNavigation } from './mobile-navigation'; export type HeaderProps = HTMLAttributes & { user: User; + teams: GetTeamsResponse; }; -export const Header = ({ className, user, ...props }: HeaderProps) => { +export const Header = ({ className, user, teams, ...props }: HeaderProps) => { + const params = useParams(); + + const [isCommandMenuOpen, setIsCommandMenuOpen] = useState(false); + const [isHamburgerMenuOpen, setIsHamburgerMenuOpen] = useState(false); const [scrollY, setScrollY] = useState(0); useEffect(() => { @@ -41,8 +52,8 @@ export const Header = ({ className, user, ...props }: HeaderProps) => { >
@@ -50,11 +61,24 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
- + +
- {/* */} +
+ + + + + + +
diff --git a/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx new file mode 100644 index 000000000..35a05baf2 --- /dev/null +++ b/apps/web/src/components/(dashboard)/layout/menu-switcher.tsx @@ -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 ( + + + + + + + {teams ? ( + <> + Personal + + + + + ) + } + /> + + + + + + +
+

Teams

+ +
+ + + + + + + +
+
+
+ + {teams.map((team) => ( + + + + ) + } + /> + + + ))} + + ) : ( + + + Create team + + + + )} + + + + {isUserAdmin && ( + + Admin panel + + )} + + + User settings + + + {selectedTeam && + canExecuteTeamAction('MANAGE_TEAM', selectedTeam.currentTeamMember.role) && ( + + Team settings + + )} + + + signOut({ + callbackUrl: '/', + }) + } + > + Sign Out + +
+
+ ); +}; diff --git a/apps/web/src/components/(dashboard)/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/layout/mobile-nav.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx new file mode 100644 index 000000000..7142de5dc --- /dev/null +++ b/apps/web/src/components/(dashboard)/layout/mobile-navigation.tsx @@ -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 ( + + + + Documenso Logo + + +
+ {menuNavigationLinks.map(({ href, text }) => ( + handleMenuItemClick()} + > + {text} + + ))} + + +
+ +
+
+ +
+ +

+ © {new Date().getFullYear()} Documenso, Inc. All rights reserved. +

+
+
+
+ ); +}; diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx deleted file mode 100644 index f2432c071..000000000 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ /dev/null @@ -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 ( - - - - - - - Account - - {isUserAdmin && ( - <> - - - - Admin - - - - - - )} - - - - - Profile - - - - - - - Security - - - - {isBillingEnabled && ( - - - - Billing - - - )} - - - - - - Templates - - - - - - - - Themes - - - - - - Light - - - - Dark - - - - System - - - - - - - - - - Star on Github - - - - - - - void signOut({ - callbackUrl: '/', - }) - } - > - - Sign Out - - - - ); -}; diff --git a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx b/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx index caeb780d0..a49e2f284 100644 --- a/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx +++ b/apps/web/src/components/(dashboard)/period-selector/period-selector.tsx @@ -21,9 +21,9 @@ export const PeriodSelector = () => { const router = useRouter(); const period = useMemo(() => { - const p = searchParams?.get('period') ?? ''; + const p = searchParams?.get('period') ?? 'all'; - return isPeriodSelectorValue(p) ? p : ''; + return isPeriodSelectorValue(p) ? p : 'all'; }, [searchParams]); const onPeriodChange = (newPeriod: string) => { @@ -35,7 +35,7 @@ export const PeriodSelector = () => { params.set('period', newPeriod); - if (newPeriod === '') { + if (newPeriod === '' || newPeriod === 'all') { params.delete('period'); } @@ -49,7 +49,7 @@ export const PeriodSelector = () => { - All Time + All Time Last 7 days Last 14 days Last 30 days diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index f4b2aae5e..c7ab61d8a 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -1,11 +1,11 @@ 'use client'; -import { HTMLAttributes } from 'react'; +import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { CreditCard, Lock, User } from 'lucide-react'; +import { CreditCard, Lock, User, Users } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { + + + + + + + + + )} + + + + + Add team email + + + A verification email will be sent to the provided email. + + + +
+ +
+ ( + + Name + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + + + + + +
+
+ +
+ + ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx new file mode 100644 index 000000000..f7ee8ca51 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/create-team-checkout-dialog.tsx @@ -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; + +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 ( + + + + Team checkout + + + Payment is required to finalise the creation of your team. + + + + {(isLoading || !data) && ( +
+ {isLoading ? ( + + ) : ( +

Something went wrong

+ )} +
+ )} + + {data && selectedPrice && !isLoading && ( +
+ setInterval(value as 'monthly' | 'yearly')} + value={interval} + className="mb-4" + > + + {[data.monthly, data.yearly].map((price) => ( + + {price.friendlyInterval} + + ))} + + + + + + + {selectedPrice.interval === 'monthly' ? ( +
+ $50 USD per month +
+ ) : ( +
+ + $480 USD per year + +
+ + 20% off +
+
+ )} + +
+

This price includes minimum 5 seats.

+ +

+ Adding and removing seats will adjust your invoice accordingly. +

+
+
+
+
+ + + + + + +
+ )} +
+
+ ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx new file mode 100644 index 000000000..283fd8dad --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/create-team-dialog.tsx @@ -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; + +const ZCreateTeamFormSchema = ZCreateTeamMutationSchema.pick({ + teamName: true, + teamUrl: true, +}); + +type TCreateTeamFormSchema = z.infer; + +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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild={true}> + {trigger ?? ( + + )} + + + + + Create team + + + Create a team to collaborate with your team members. + + + +
+ +
+ ( + + Team Name + + { + 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); + }} + /> + + + + )} + /> + + ( + + Team URL + + + + {!form.formState.errors.teamUrl && ( + + {field.value + ? `${WEBAPP_BASE_URL}/t/${field.value}` + : 'A unique URL to identify your team'} + + )} + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx new file mode 100644 index 000000000..99630e57c --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/delete-team-dialog.tsx @@ -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 ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? } + + + + + Delete team + + + Are you sure? This is irreversable. + + + +
+ +
+ ( + + + Confirm by typing {deleteMessage} + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx new file mode 100644 index 000000000..7ae8ccf1c --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/delete-team-member-dialog.tsx @@ -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 ( + !isDeletingTeamMember && setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to remove the following user from{' '} + {teamName}. + + + + + {teamMemberName}} + secondaryText={teamMemberEmail} + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx new file mode 100644 index 000000000..482142c99 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/invite-team-member-dialog.tsx @@ -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; + +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; + +export const InviteTeamMembersDialog = ({ + currentUserTeamRole, + teamId, + trigger, + ...props +}: InviteTeamMembersDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Invite team members + + + An email containing an invitation will be sent to each member. + + + +
+ +
+ {teamMemberInvites.map((teamMemberInvite, index) => ( +
+ ( + + {index === 0 && Email address} + + + + + + )} + /> + + ( + + {index === 0 && Role} + + + + + + )} + /> + + +
+ ))} + + + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx new file mode 100644 index 000000000..27384d680 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/leave-team-dialog.tsx @@ -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 ( + !isLeavingTeam && setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to leave the following team. + + + + + + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx new file mode 100644 index 000000000..e5dd8ca17 --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx @@ -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>({ + resolver: zodResolver(ZTransferTeamFormSchema), + defaultValues: { + teamName: '', + clearPaymentMethods: false, + }, + }); + + const onFormSubmit = async ({ + newOwnerUserId, + clearPaymentMethods, + }: z.infer) => { + 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 ( + !form.formState.isSubmitting && setOpen(value)}> + + {trigger ?? ( + + )} + + + {teamMembers && teamMembers.length > 0 ? ( + + + Transfer team + + + Transfer ownership of this team to a selected team member. + + + +
+ +
+ ( + + New team owner + + + + + + )} + /> + + ( + + + Confirm by typing{' '} + {confirmTransferMessage} + + + + + + + )} + /> + + {/* Temporary removed. */} + {/* {IS_BILLING_ENABLED && ( + ( + +
+ + + +
+
+ )} + /> + )} */} + + + +
    + {IS_BILLING_ENABLED && ( + // Temporary removed. + //
  • + // {form.getValues('clearPaymentMethods') + // ? 'You will not be billed for any upcoming invoices' + // : 'We will continue to bill current payment methods if required'} + //
  • + +
  • + Any payment methods attached to this team will remain attached to this + team. Please contact us if you need to update this information. +
  • + )} +
  • + The selected team member will receive an email which they must accept before + the team is transferred +
  • +
+
+
+ + + + + + +
+
+ +
+ ) : ( + + {loadingTeamMembers ? ( + + ) : ( +

+ {loadingTeamMembersError + ? 'An error occurred while loading team members. Please try again later.' + : 'You must have at least one other team member to transfer ownership.'} +

+ )} +
+ )} +
+ ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx new file mode 100644 index 000000000..c6ab8890a --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx @@ -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; + +const ZUpdateTeamEmailFormSchema = z.object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), +}); + +type TUpdateTeamEmailFormSchema = z.infer; + +export const UpdateTeamEmailDialog = ({ + teamEmail, + trigger, + ...props +}: UpdateTeamEmailDialogProps) => { + const router = useRouter(); + + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + Update team email + + + To change the email you must remove and add a new email address. + + + +
+ +
+ ( + + Name + + + + + + )} + /> + + + Email + + + + + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx new file mode 100644 index 000000000..cc8ea675f --- /dev/null +++ b/apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx @@ -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; + +const ZUpdateTeamMemberFormSchema = z.object({ + role: z.nativeEnum(TeamMemberRole), +}); + +type ZUpdateTeamMemberSchema = z.infer; + +export const UpdateTeamMemberDialog = ({ + currentUserTeamRole, + trigger, + teamId, + teamMemberId, + teamMemberName, + teamMemberRole, + ...props +}: UpdateTeamMemberDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + 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 ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Update team member + + + You are currently updating {teamMemberName}. + + + +
+ +
+ ( + + Role + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/components/(teams)/forms/update-team-form.tsx b/apps/web/src/components/(teams)/forms/update-team-form.tsx new file mode 100644 index 000000000..142914b8c --- /dev/null +++ b/apps/web/src/components/(teams)/forms/update-team-form.tsx @@ -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; + +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 ( +
+ +
+ ( + + Team Name + + + + + + )} + /> + + ( + + Team URL + + + + {!form.formState.errors.url && ( + + {field.value + ? `${WEBAPP_BASE_URL}/t/${field.value}` + : 'A unique URL to identify your team'} + + )} + + + + )} + /> + +
+ + {form.formState.isDirty && ( + + + + )} + + + +
+
+
+ + ); +}; diff --git a/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx new file mode 100644 index 000000000..be68f6c03 --- /dev/null +++ b/apps/web/src/components/(teams)/settings/layout/desktop-nav.tsx @@ -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; + +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 ( +
+ + + + + + + + + {IS_BILLING_ENABLED && ( + + + + )} +
+ ); +}; diff --git a/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx new file mode 100644 index 000000000..de01ca9bf --- /dev/null +++ b/apps/web/src/components/(teams)/settings/layout/mobile-nav.tsx @@ -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; + +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 ( +
+ + + + + + + + + {IS_BILLING_ENABLED && ( + + + + )} +
+ ); +}; diff --git a/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx new file mode 100644 index 000000000..0dd4bcf4c --- /dev/null +++ b/apps/web/src/components/(teams)/tables/current-user-teams-data-table.tsx @@ -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 ( + ( + + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + /> + + ), + }, + { + 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 }) => , + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ {canExecuteTeamAction('MANAGE_TEAM', row.original.currentTeamMember.role) && ( + + )} + + e.preventDefault()} + > + Leave + + } + /> +
+ ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+ + +
+
+
+ + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx new file mode 100644 index 000000000..64a58375c --- /dev/null +++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table-actions.tsx @@ -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 ( +
+ + + +
+ ); +}; diff --git a/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx new file mode 100644 index 000000000..84d4e38df --- /dev/null +++ b/apps/web/src/components/(teams)/tables/pending-user-teams-data-table.tsx @@ -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(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 ( + <> + ( + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/t/${row.original.url}`} + /> + ), + }, + { + header: 'Created on', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + id: 'actions', + cell: ({ row }) => ( + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+ + +
+
+
+ + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ + setCheckoutPendingTeamId(null)} + /> + + ); +}; diff --git a/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx new file mode 100644 index 000000000..a860ac6d9 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-billing-invoices-data-table.tsx @@ -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 ( + ( +
+ + +
+ + {DateTime.fromSeconds(row.original.created).toFormat('MMMM yyyy')} + + + {row.original.quantity} {row.original.quantity > 1 ? 'Seats' : 'Seat'} + +
+
+ ), + }, + { + 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 }) => ( +
+ + + +
+ ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+ + +
+
+
+ + + + + + + +
+ + +
+
+ + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx new file mode 100644 index 000000000..f0e3580e3 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-member-invites-data-table.tsx @@ -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 ( + { + return ( + {row.original.email} + } + /> + ); + }, + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => TEAM_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role, + }, + { + header: 'Invited At', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( + + + + + + + Actions + + + resendTeamMemberInvitation({ + teamId, + invitationId: row.original.id, + }) + } + > + + Resend + + + + deleteTeamMemberInvitations({ + teamId, + invitationIds: [row.original.id], + }) + } + > + + Remove + + + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+
+ + + + + + + + + + + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/web/src/components/(teams)/tables/team-members-data-table.tsx b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx new file mode 100644 index 000000000..3002ecbb0 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/team-members-data-table.tsx @@ -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 ( + { + const avatarFallbackText = row.original.user.name + ? extractInitials(row.original.user.name) + : row.original.user.email.slice(0, 1).toUpperCase(); + + return ( + {row.original.user.name} + } + 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 }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( + + + + + + + Actions + + e.preventDefault()} + title="Update team member role" + > + + Update role + + } + /> + + e.preventDefault()} + disabled={ + teamOwnerUserId === row.original.userId || + !isTeamRoleWithinUserHierarchy(currentUserTeamRole, row.original.role) + } + title="Remove team member" + > + + Remove + + } + /> + + + ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + +
+ + +
+ + +
+
+
+ + + + + + + + + + + ), + }} + > + {(table) => } +
+ ); +}; diff --git a/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx new file mode 100644 index 000000000..316c4373f --- /dev/null +++ b/apps/web/src/components/(teams)/tables/teams-member-page-data-table.tsx @@ -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 ( +
+
+ setSearchQuery(e.target.value)} + placeholder="Search" + /> + + + + + All + + + + Pending + + + +
+ + {currentTab === 'invites' ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx b/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx new file mode 100644 index 000000000..277421263 --- /dev/null +++ b/apps/web/src/components/(teams)/tables/user-settings-teams-page-data-table.tsx @@ -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 ( +
+
+ setSearchQuery(e.target.value)} + placeholder="Search" + /> + + + + + Active + + + + + Pending + {data && data.count > 0 && ( + {data.count} + )} + + + + +
+ + {currentTab === 'pending' ? : } +
+ ); +}; diff --git a/apps/web/src/components/(teams)/team-billing-portal-button.tsx b/apps/web/src/components/(teams)/team-billing-portal-button.tsx new file mode 100644 index 000000000..808b9b9ba --- /dev/null +++ b/apps/web/src/components/(teams)/team-billing-portal-button.tsx @@ -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; + 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 ( + + ); +}; diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index b3e4ea019..b21e9621b 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -55,10 +55,11 @@ export type TSignInFormSchema = z.infer; export type SignInFormProps = { className?: string; + initialEmail?: string; isGoogleSSOEnabled?: boolean; }; -export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => { +export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => { const { toast } = useToast(); const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = useState(false); @@ -69,7 +70,7 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) = const form = useForm({ values: { - email: '', + email: initialEmail ?? '', password: '', totpCode: '', backupCode: '', diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index f38ab15d1..430c7ebdf 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -48,17 +48,18 @@ export type TSignUpFormSchema = z.infer; export type SignUpFormProps = { className?: string; + initialEmail?: string; isGoogleSSOEnabled?: boolean; }; -export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => { +export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => { const { toast } = useToast(); const analytics = useAnalytics(); const form = useForm({ values: { name: '', - email: '', + email: initialEmail ?? '', password: '', signature: '', }, diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 25bfbbb40..46ee93fdf 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -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 { TEAM_URL_ROOT_REGEX } from '@documenso/lib/constants/teams'; +import { formatDocumentsPath } from '@documenso/lib/utils/teams'; + 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 === '/') { - 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); } + // Redirect `/t/` to `/t//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')) { 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(); } + +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' }, + ], + }, + ], +}; diff --git a/package-lock.json b/package-lock.json index 9012d3f29..aae034c57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4886,9 +4886,9 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-1.2.2.tgz", - "integrity": "sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", + "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==", "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/number": "1.0.1", @@ -4897,12 +4897,12 @@ "@radix-ui/react-compose-refs": "1.0.1", "@radix-ui/react-context": "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-scope": "1.0.3", + "@radix-ui/react-focus-scope": "1.0.4", "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-popper": "1.1.2", - "@radix-ui/react-portal": "1.0.3", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", "@radix-ui/react-primitive": "1.0.3", "@radix-ui/react-slot": "1.0.2", "@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": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", @@ -19750,13 +19643,19 @@ "@prisma/client": "5.4.2", "dotenv": "^16.3.1", "dotenv-cli": "^7.3.0", - "prisma": "5.4.2" + "prisma": "5.4.2", + "ts-pattern": "^5.0.6" }, "devDependencies": { "ts-node": "^10.9.1", "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": { "version": "5.2.2", "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-collapsible": "^1.0.2", "@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-hover-card": "^1.0.5", "@radix-ui/react-label": "^2.0.1", @@ -19874,7 +19773,7 @@ "@radix-ui/react-progress": "^1.0.2", "@radix-ui/react-radio-group": "^1.1.2", "@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-slider": "^1.1.1", "@radix-ui/react-slot": "^1.0.2", diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts new file mode 100644 index 000000000..f1926fb2a --- /dev/null +++ b/packages/app-tests/e2e/fixtures/authentication.ts @@ -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`); +}; diff --git a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts index 12a099bbf..da95c66f0 100644 --- a/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts +++ b/packages/app-tests/e2e/pr-711-deletion-of-documents.spec.ts @@ -2,6 +2,8 @@ import { expect, test } from '@playwright/test'; import { TEST_USERS } from '@documenso/prisma/seed/pr-711-deletion-of-documents'; +import { manualLogin, manualSignout } from './fixtures/authentication'; + test.describe.configure({ mode: 'serial' }); 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 - Draft' })).toBeVisible(); - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); for (const recipient of recipients) { - await page.goto('/signin'); - - 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('/signin'); + await manualLogin({ page, email: recipient.email, password: recipient.password }); 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 page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); } }); @@ -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(); - // signout - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); for (const recipient of recipients) { + await page.waitForURL('/signin'); await page.goto('/signin'); // 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 page.goto('/documents'); - - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); } }); @@ -115,11 +101,7 @@ test('[PR-711]: deleting a pending document should remove it from recipients', a await page.goto('/signin'); - // 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 manualLogin({ page, email: sender.email, password: sender.password }); await page.waitForURL('/documents'); // 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(); // signout - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); for (const recipient of recipients) { - await page.goto('/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 page.waitForURL('/signin'); + await manualLogin({ page, email: recipient.email, password: recipient.password }); await page.waitForURL('/documents'); 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 page.goto('/documents'); + await page.waitForURL('/documents'); - await page.getByTitle('Profile Dropdown').click(); - await page.getByRole('menuitem', { name: 'Sign Out' }).click(); - - await page.waitForURL('/signin'); + await manualSignout({ page }); } }); @@ -167,13 +140,7 @@ test('[PR-711]: deleting a draft document should remove it without additional pr }) => { const [sender] = TEST_USERS; - await page.goto('/signin'); - - // 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 manualLogin({ page, email: sender.email, password: sender.password }); await page.waitForURL('/documents'); // open actions menu diff --git a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts index e9ae60d0e..160113f95 100644 --- a/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts +++ b/packages/app-tests/e2e/pr-713-add-document-search-to-command-menu.spec.ts @@ -17,12 +17,6 @@ test('[PR-713]: should see sent documents', async ({ page }) => { await page.getByPlaceholder('Type a command or search...').fill('sent'); 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 }) => { @@ -40,12 +34,6 @@ test('[PR-713]: should see received documents', async ({ page }) => { await page.getByPlaceholder('Type a command or search...').fill('received'); 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 }) => { @@ -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 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(); }); diff --git a/packages/app-tests/e2e/teams/manage-team.spec.ts b/packages/app-tests/e2e/teams/manage-team.spec.ts new file mode 100644 index 000000000..aed56b2bc --- /dev/null +++ b/packages/app-tests/e2e/teams/manage-team.spec.ts @@ -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); +}); diff --git a/packages/app-tests/e2e/teams/team-documents.spec.ts b/packages/app-tests/e2e/teams/team-documents.spec.ts new file mode 100644 index 000000000..210189ca7 --- /dev/null +++ b/packages/app-tests/e2e/teams/team-documents.spec.ts @@ -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'); +}); diff --git a/packages/app-tests/e2e/teams/team-email.spec.ts b/packages/app-tests/e2e/teams/team-email.spec.ts new file mode 100644 index 000000000..953be5aaf --- /dev/null +++ b/packages/app-tests/e2e/teams/team-email.spec.ts @@ -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); +}); diff --git a/packages/app-tests/e2e/teams/team-members.spec.ts b/packages/app-tests/e2e/teams/team-members.spec.ts new file mode 100644 index 000000000..05f096c09 --- /dev/null +++ b/packages/app-tests/e2e/teams/team-members.spec.ts @@ -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); +}); diff --git a/packages/app-tests/e2e/teams/transfer-team.spec.ts b/packages/app-tests/e2e/teams/transfer-team.spec.ts new file mode 100644 index 000000000..a5d95b720 --- /dev/null +++ b/packages/app-tests/e2e/teams/transfer-team.spec.ts @@ -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); +}); diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts index 45b6dea03..40ee5e768 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -30,7 +30,7 @@ test('user can sign up with email and password', async ({ page }: { page: Page } 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 expect(page).toHaveURL('/documents'); diff --git a/packages/ee/server-only/limits/client.ts b/packages/ee/server-only/limits/client.ts index 7f48e6856..9a36928b1 100644 --- a/packages/ee/server-only/limits/client.ts +++ b/packages/ee/server-only/limits/client.ts @@ -1,17 +1,23 @@ import { APP_BASE_URL } from '@documenso/lib/constants/app'; import { FREE_PLAN_LIMITS } from './constants'; -import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema'; +import type { TLimitsResponseSchema } from './schema'; +import { ZLimitsResponseSchema } from './schema'; export type GetLimitsOptions = { headers?: Record; + teamId?: number | null; }; -export const getLimits = async ({ headers }: GetLimitsOptions = {}) => { +export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => { const requestHeaders = headers ?? {}; const url = new URL(`${APP_BASE_URL}/api/limits`); + if (teamId) { + requestHeaders['team-id'] = teamId.toString(); + } + return fetch(url, { headers: { ...requestHeaders, diff --git a/packages/ee/server-only/limits/constants.ts b/packages/ee/server-only/limits/constants.ts index 71ff29d9d..4c428f34f 100644 --- a/packages/ee/server-only/limits/constants.ts +++ b/packages/ee/server-only/limits/constants.ts @@ -1,10 +1,15 @@ -import { TLimitsSchema } from './schema'; +import type { TLimitsSchema } from './schema'; export const FREE_PLAN_LIMITS: TLimitsSchema = { documents: 5, recipients: 10, }; +export const TEAM_PLAN_LIMITS: TLimitsSchema = { + documents: Infinity, + recipients: Infinity, +}; + export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = { documents: Infinity, recipients: Infinity, diff --git a/packages/ee/server-only/limits/handler.ts b/packages/ee/server-only/limits/handler.ts index 69f77db75..a497b2314 100644 --- a/packages/ee/server-only/limits/handler.ts +++ b/packages/ee/server-only/limits/handler.ts @@ -1,10 +1,10 @@ -import { NextApiRequest, NextApiResponse } from 'next'; +import type { NextApiRequest, NextApiResponse } from 'next'; import { getToken } from 'next-auth/jwt'; import { match } from 'ts-pattern'; import { ERROR_CODES } from './errors'; -import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema'; +import type { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema'; import { getServerLimits } from './server'; export const limitsHandler = async ( @@ -14,7 +14,19 @@ export const limitsHandler = async ( try { 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); } catch (err) { diff --git a/packages/ee/server-only/limits/provider/client.tsx b/packages/ee/server-only/limits/provider/client.tsx index 07a085750..fdc00b439 100644 --- a/packages/ee/server-only/limits/provider/client.tsx +++ b/packages/ee/server-only/limits/provider/client.tsx @@ -6,7 +6,7 @@ import { equals } from 'remeda'; import { getLimits } from '../client'; import { FREE_PLAN_LIMITS } from '../constants'; -import { TLimitsResponseSchema } from '../schema'; +import type { TLimitsResponseSchema } from '../schema'; export type LimitsContextValue = TLimitsResponseSchema; @@ -24,19 +24,22 @@ export const useLimits = () => { export type LimitsProviderProps = { initialValue?: LimitsContextValue; + teamId?: number; children?: React.ReactNode; }; -export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => { - const defaultValue: TLimitsResponseSchema = { +export const LimitsProvider = ({ + initialValue = { quota: FREE_PLAN_LIMITS, remaining: FREE_PLAN_LIMITS, - }; - - const [limits, setLimits] = useState(() => initialValue ?? defaultValue); + }, + teamId, + children, +}: LimitsProviderProps) => { + const [limits, setLimits] = useState(() => initialValue); const refreshLimits = async () => { - const newLimits = await getLimits(); + const newLimits = await getLimits({ teamId }); setLimits((oldLimits) => { if (equals(oldLimits, newLimits)) { diff --git a/packages/ee/server-only/limits/provider/server.tsx b/packages/ee/server-only/limits/provider/server.tsx index c9295483a..b7cde3573 100644 --- a/packages/ee/server-only/limits/provider/server.tsx +++ b/packages/ee/server-only/limits/provider/server.tsx @@ -3,16 +3,22 @@ import { headers } from 'next/headers'; import { getLimits } from '../client'; +import type { LimitsContextValue } from './client'; import { LimitsProvider as ClientLimitsProvider } from './client'; export type LimitsProviderProps = { 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 limits = await getLimits({ headers: requestHeaders }); + const limits: LimitsContextValue = await getLimits({ headers: requestHeaders, teamId }); - return {children}; + return ( + + {children} + + ); }; diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index f256c6356..e48eb7187 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -1,22 +1,22 @@ 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 { SubscriptionStatus } from '@documenso/prisma/client'; -import { getPricesByType } from '../stripe/get-prices-by-type'; -import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants'; +import { getPricesByPlan } from '../stripe/get-prices-by-plan'; +import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants'; import { ERROR_CODES } from './errors'; import { ZLimitsSchema } from './schema'; export type GetServerLimitsOptions = { email?: string | null; + teamId?: number | null; }; -export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { - const isBillingEnabled = await getFlag('app_billing'); - - if (!isBillingEnabled) { +export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => { + if (!IS_BILLING_ENABLED) { return { quota: SELFHOSTED_PLAN_LIMITS, remaining: SELFHOSTED_PLAN_LIMITS, @@ -27,6 +27,14 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { 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({ where: { email, @@ -48,10 +56,10 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { ); if (activeSubscriptions.length > 0) { - const individualPrices = await getPricesByType('individual'); + const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY); 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) { continue; } @@ -71,6 +79,7 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { const documents = await prisma.document.count({ where: { userId: user.id, + teamId: null, createdAt: { gte: DateTime.utc().startOf('month').toJSDate(), }, @@ -84,3 +93,50 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => { 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), + }; +}; diff --git a/packages/ee/server-only/stripe/create-team-customer.ts b/packages/ee/server-only/stripe/create-team-customer.ts new file mode 100644 index 000000000..591c445af --- /dev/null +++ b/packages/ee/server-only/stripe/create-team-customer.ts @@ -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, + }, + }); +}; diff --git a/packages/ee/server-only/stripe/delete-customer-payment-methods.ts b/packages/ee/server-only/stripe/delete-customer-payment-methods.ts new file mode 100644 index 000000000..749c15763 --- /dev/null +++ b/packages/ee/server-only/stripe/delete-customer-payment-methods.ts @@ -0,0 +1,22 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +type DeleteCustomerPaymentMethodsOptions = { + customerId: string; +}; + +/** + * Delete all attached payment methods for a given customer. + */ +export const deleteCustomerPaymentMethods = async ({ + customerId, +}: DeleteCustomerPaymentMethodsOptions) => { + const paymentMethods = await stripe.paymentMethods.list({ + customer: customerId, + }); + + await Promise.all( + paymentMethods.data.map(async (paymentMethod) => + stripe.paymentMethods.detach(paymentMethod.id), + ), + ); +}; diff --git a/packages/ee/server-only/stripe/get-checkout-session.ts b/packages/ee/server-only/stripe/get-checkout-session.ts index fd15d538a..7c89c1f8c 100644 --- a/packages/ee/server-only/stripe/get-checkout-session.ts +++ b/packages/ee/server-only/stripe/get-checkout-session.ts @@ -1,17 +1,21 @@ 'use server'; +import type Stripe from 'stripe'; + import { stripe } from '@documenso/lib/server-only/stripe'; export type GetCheckoutSessionOptions = { customerId: string; priceId: string; returnUrl: string; + subscriptionMetadata?: Stripe.Metadata; }; export const getCheckoutSession = async ({ customerId, priceId, returnUrl, + subscriptionMetadata, }: GetCheckoutSessionOptions) => { 'use server'; @@ -26,6 +30,9 @@ export const getCheckoutSession = async ({ ], success_url: `${returnUrl}?success=true`, cancel_url: `${returnUrl}?canceled=true`, + subscription_data: { + metadata: subscriptionMetadata, + }, }); return session.url; diff --git a/packages/ee/server-only/stripe/get-community-plan-prices.ts b/packages/ee/server-only/stripe/get-community-plan-prices.ts new file mode 100644 index 000000000..86c7f61bd --- /dev/null +++ b/packages/ee/server-only/stripe/get-community-plan-prices.ts @@ -0,0 +1,13 @@ +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +export const getCommunityPlanPrices = async () => { + return await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY); +}; + +export const getCommunityPlanPriceIds = async () => { + const prices = await getCommunityPlanPrices(); + + return prices.map((price) => price.id); +}; diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts index c85488e6f..6e2d4f088 100644 --- a/packages/ee/server-only/stripe/get-customer.ts +++ b/packages/ee/server-only/stripe/get-customer.ts @@ -1,15 +1,19 @@ +import { STRIPE_CUSTOMER_TYPE } from '@documenso/lib/constants/billing'; import { stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; import type { User } from '@documenso/prisma/client'; import { onSubscriptionUpdated } from './webhook/on-subscription-updated'; +/** + * Get a non team Stripe customer by email. + */ export const getStripeCustomerByEmail = async (email: string) => { const foundStripeCustomers = await stripe.customers.list({ email, }); - return foundStripeCustomers.data[0] ?? null; + return foundStripeCustomers.data.find((customer) => customer.metadata.type !== 'team') ?? null; }; export const getStripeCustomerById = async (stripeCustomerId: string) => { @@ -51,6 +55,7 @@ export const getStripeCustomerByUser = async (user: User) => { email: user.email, metadata: { userId: user.id, + type: STRIPE_CUSTOMER_TYPE.INDIVIDUAL, }, }); } @@ -78,6 +83,14 @@ export const getStripeCustomerByUser = async (user: User) => { }; }; +export const getStripeCustomerIdByUser = async (user: User) => { + if (user.customerId !== null) { + return user.customerId; + } + + return await getStripeCustomerByUser(user).then((session) => session.stripeCustomer.id); +}; + const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => { const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId, diff --git a/packages/ee/server-only/stripe/get-invoices.ts b/packages/ee/server-only/stripe/get-invoices.ts new file mode 100644 index 000000000..f8f383921 --- /dev/null +++ b/packages/ee/server-only/stripe/get-invoices.ts @@ -0,0 +1,11 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +export type GetInvoicesOptions = { + customerId: string; +}; + +export const getInvoices = async ({ customerId }: GetInvoicesOptions) => { + return await stripe.invoices.list({ + customer: customerId, + }); +}; diff --git a/packages/ee/server-only/stripe/get-portal-session.ts b/packages/ee/server-only/stripe/get-portal-session.ts index 310cc1e47..275d166d8 100644 --- a/packages/ee/server-only/stripe/get-portal-session.ts +++ b/packages/ee/server-only/stripe/get-portal-session.ts @@ -4,7 +4,7 @@ import { stripe } from '@documenso/lib/server-only/stripe'; export type GetPortalSessionOptions = { customerId: string; - returnUrl: string; + returnUrl?: string; }; export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => { diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts index a5578a813..1b528706a 100644 --- a/packages/ee/server-only/stripe/get-prices-by-interval.ts +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -9,12 +9,12 @@ export type PriceIntervals = Record { +export const getPricesByInterval = async ({ plan }: GetPricesByIntervalOptions = {}) => { let { data: prices } = await stripe.prices.search({ query: `active:'true' type:'recurring'`, expand: ['data.product'], @@ -26,7 +26,7 @@ export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const product = price.product as Stripe.Product; - const filter = !type || product.metadata?.type === type; + const filter = !plan || product.metadata?.plan === plan; // Filter out prices for products that are not active. return product.active && filter; diff --git a/packages/ee/server-only/stripe/get-prices-by-plan.ts b/packages/ee/server-only/stripe/get-prices-by-plan.ts new file mode 100644 index 000000000..5c390b35a --- /dev/null +++ b/packages/ee/server-only/stripe/get-prices-by-plan.ts @@ -0,0 +1,14 @@ +import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; +import { stripe } from '@documenso/lib/server-only/stripe'; + +export const getPricesByPlan = async ( + plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE], +) => { + const { data: prices } = await stripe.prices.search({ + query: `metadata['plan']:'${plan}' type:'recurring'`, + expand: ['data.product'], + limit: 100, + }); + + return prices; +}; diff --git a/packages/ee/server-only/stripe/get-prices-by-type.ts b/packages/ee/server-only/stripe/get-prices-by-type.ts deleted file mode 100644 index 22124562c..000000000 --- a/packages/ee/server-only/stripe/get-prices-by-type.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { stripe } from '@documenso/lib/server-only/stripe'; - -export const getPricesByType = async (type: 'individual') => { - const { data: prices } = await stripe.prices.search({ - query: `metadata['type']:'${type}' type:'recurring'`, - expand: ['data.product'], - limit: 100, - }); - - return prices; -}; diff --git a/packages/ee/server-only/stripe/get-team-prices.ts b/packages/ee/server-only/stripe/get-team-prices.ts new file mode 100644 index 000000000..5c3021b78 --- /dev/null +++ b/packages/ee/server-only/stripe/get-team-prices.ts @@ -0,0 +1,43 @@ +import type Stripe from 'stripe'; + +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; +import { AppError } from '@documenso/lib/errors/app-error'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +export const getTeamPrices = async () => { + const prices = (await getPricesByPlan(STRIPE_PLAN_TYPE.TEAM)).filter((price) => price.active); + + const monthlyPrice = prices.find((price) => price.recurring?.interval === 'month'); + const yearlyPrice = prices.find((price) => price.recurring?.interval === 'year'); + const priceIds = prices.map((price) => price.id); + + if (!monthlyPrice || !yearlyPrice) { + throw new AppError('INVALID_CONFIG', 'Missing monthly or yearly price'); + } + + return { + monthly: { + friendlyInterval: 'Monthly', + interval: 'monthly', + ...extractPriceData(monthlyPrice), + }, + yearly: { + friendlyInterval: 'Yearly', + interval: 'yearly', + ...extractPriceData(yearlyPrice), + }, + priceIds, + } as const; +}; + +const extractPriceData = (price: Stripe.Price) => { + const product = + typeof price.product !== 'string' && !price.product.deleted ? price.product : null; + + return { + priceId: price.id, + description: product?.description ?? '', + features: product?.features ?? [], + }; +}; diff --git a/packages/ee/server-only/stripe/transfer-team-subscription.ts b/packages/ee/server-only/stripe/transfer-team-subscription.ts new file mode 100644 index 000000000..b4e0bd59a --- /dev/null +++ b/packages/ee/server-only/stripe/transfer-team-subscription.ts @@ -0,0 +1,126 @@ +import type Stripe from 'stripe'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { stripe } from '@documenso/lib/server-only/stripe'; +import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import { type Subscription, type Team, type User } from '@documenso/prisma/client'; + +import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods'; +import { getCommunityPlanPriceIds } from './get-community-plan-prices'; +import { getTeamPrices } from './get-team-prices'; + +type TransferStripeSubscriptionOptions = { + /** + * The user to transfer the subscription to. + */ + user: User & { Subscription: Subscription[] }; + + /** + * The team the subscription is associated with. + */ + team: Team & { subscription?: Subscription | null }; + + /** + * Whether to clear any current payment methods attached to the team. + */ + clearPaymentMethods: boolean; +}; + +/** + * Transfer the Stripe Team seats subscription from one user to another. + * + * Will create a new subscription for the new owner and cancel the old one. + * + * Returns the subscription that should be associated with the team, null if + * no subscription is needed (for community plan). + */ +export const transferTeamSubscription = async ({ + user, + team, + clearPaymentMethods, +}: TransferStripeSubscriptionOptions) => { + const teamCustomerId = team.customerId; + + if (!teamCustomerId) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.'); + } + + const [communityPlanIds, teamSeatPrices] = await Promise.all([ + getCommunityPlanPriceIds(), + getTeamPrices(), + ]); + + const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan( + user.Subscription, + communityPlanIds, + ); + + let teamSubscription: Stripe.Subscription | null = null; + + if (team.subscription) { + teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId); + + if (!teamSubscription) { + throw new Error('Could not find the current subscription.'); + } + + if (clearPaymentMethods) { + await deleteCustomerPaymentMethods({ customerId: teamCustomerId }); + } + } + + await stripe.customers.update(teamCustomerId, { + name: user.name ?? team.name, + email: user.email, + }); + + // If team subscription is required and the team does not have a subscription, create one. + if (teamSubscriptionRequired && !teamSubscription) { + const numberOfSeats = await prisma.teamMember.count({ + where: { + teamId: team.id, + }, + }); + + const teamSeatPriceId = teamSeatPrices.monthly.priceId; + + teamSubscription = await stripe.subscriptions.create({ + customer: teamCustomerId, + items: [ + { + price: teamSeatPriceId, + quantity: numberOfSeats, + }, + ], + metadata: { + teamId: team.id.toString(), + }, + }); + } + + // If no team subscription is required, cancel the current team subscription if it exists. + if (!teamSubscriptionRequired && teamSubscription) { + try { + // Set the quantity to 0 so we can refund/charge the old Stripe customer the prorated amount. + await stripe.subscriptions.update(teamSubscription.id, { + items: teamSubscription.items.data.map((item) => ({ + id: item.id, + quantity: 0, + })), + }); + + await stripe.subscriptions.cancel(teamSubscription.id, { + invoice_now: true, + prorate: false, + }); + } catch (e) { + // Do not error out since we can't easily undo the transfer. + // Todo: Teams - Alert us. + } + + return null; + } + + return teamSubscription; +}; diff --git a/packages/ee/server-only/stripe/update-customer.ts b/packages/ee/server-only/stripe/update-customer.ts new file mode 100644 index 000000000..78e223b48 --- /dev/null +++ b/packages/ee/server-only/stripe/update-customer.ts @@ -0,0 +1,18 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +type UpdateCustomerOptions = { + customerId: string; + name?: string; + email?: string; +}; + +export const updateCustomer = async ({ customerId, name, email }: UpdateCustomerOptions) => { + if (!name && !email) { + return; + } + + return await stripe.customers.update(customerId, { + name, + email, + }); +}; diff --git a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts new file mode 100644 index 000000000..e0fa95f3d --- /dev/null +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -0,0 +1,44 @@ +import type Stripe from 'stripe'; + +import { stripe } from '@documenso/lib/server-only/stripe'; + +export type UpdateSubscriptionItemQuantityOptions = { + subscriptionId: string; + quantity: number; + priceId: string; +}; + +export const updateSubscriptionItemQuantity = async ({ + subscriptionId, + quantity, + priceId, +}: UpdateSubscriptionItemQuantityOptions) => { + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + + const items = subscription.items.data.filter((item) => item.price.id === priceId); + + if (items.length !== 1) { + throw new Error('Subscription does not contain required item'); + } + + const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year'); + const oldQuantity = items[0].quantity; + + if (oldQuantity === quantity) { + return; + } + + const subscriptionUpdatePayload: Stripe.SubscriptionUpdateParams = { + items: items.map((item) => ({ + id: item.id, + quantity, + })), + }; + + // Only invoice immediately when changing the quantity of yearly item. + if (hasYearlyItem) { + subscriptionUpdatePayload.proration_behavior = 'always_invoice'; + } + + await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload); +}; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 047de7962..23705438a 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -3,8 +3,10 @@ import type { NextApiRequest, NextApiResponse } from 'next'; import { buffer } from 'micro'; import { match } from 'ts-pattern'; +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import type { Stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe'; +import { createTeamFromPendingTeam } from '@documenso/lib/server-only/team/create-team'; import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { prisma } from '@documenso/prisma'; @@ -84,14 +86,9 @@ export const stripeWebhookHandler = async ( }, }); - if (!result?.id) { - return res.status(500).json({ - success: false, - message: 'User not found', - }); + if (result?.id) { + userId = result.id; } - - userId = result.id; } const subscriptionId = @@ -99,7 +96,7 @@ export const stripeWebhookHandler = async ( ? session.subscription : session.subscription?.id; - if (!subscriptionId || Number.isNaN(userId)) { + if (!subscriptionId) { return res.status(500).json({ success: false, message: 'Invalid session', @@ -108,6 +105,24 @@ export const stripeWebhookHandler = async ( const subscription = await stripe.subscriptions.retrieve(subscriptionId); + // Handle team creation after seat checkout. + if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { + await handleTeamSeatCheckout({ subscription }); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + + // Validate user ID. + if (!userId || Number.isNaN(userId)) { + return res.status(500).json({ + success: false, + message: 'Invalid session or missing user ID', + }); + } + await onSubscriptionUpdated({ userId, subscription }); return res.status(200).json({ @@ -124,6 +139,28 @@ export const stripeWebhookHandler = async ( ? subscription.customer : subscription.customer.id; + if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { + const team = await prisma.team.findFirst({ + where: { + customerId, + }, + }); + + if (!team) { + return res.status(500).json({ + success: false, + message: 'No team associated with subscription found', + }); + } + + await onSubscriptionUpdated({ teamId: team.id, subscription }); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + const result = await prisma.user.findFirst({ select: { id: true, @@ -182,6 +219,28 @@ export const stripeWebhookHandler = async ( }); } + if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { + const team = await prisma.team.findFirst({ + where: { + customerId, + }, + }); + + if (!team) { + return res.status(500).json({ + success: false, + message: 'No team associated with subscription found', + }); + } + + await onSubscriptionUpdated({ teamId: team.id, subscription }); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + const result = await prisma.user.findFirst({ select: { id: true, @@ -233,6 +292,28 @@ export const stripeWebhookHandler = async ( }); } + if (subscription.items.data[0].price.metadata.plan === STRIPE_PLAN_TYPE.TEAM) { + const team = await prisma.team.findFirst({ + where: { + customerId, + }, + }); + + if (!team) { + return res.status(500).json({ + success: false, + message: 'No team associated with subscription found', + }); + } + + await onSubscriptionUpdated({ teamId: team.id, subscription }); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + const result = await prisma.user.findFirst({ select: { id: true, @@ -282,3 +363,21 @@ export const stripeWebhookHandler = async ( }); } }; + +export type HandleTeamSeatCheckoutOptions = { + subscription: Stripe.Subscription; +}; + +const handleTeamSeatCheckout = async ({ subscription }: HandleTeamSeatCheckoutOptions) => { + if (subscription.metadata?.pendingTeamId === undefined) { + throw new Error('Missing pending team ID'); + } + + const pendingTeamId = Number(subscription.metadata.pendingTeamId); + + if (Number.isNaN(pendingTeamId)) { + throw new Error('Invalid pending team ID'); + } + + return await createTeamFromPendingTeam({ pendingTeamId, subscription }).then((team) => team.id); +}; diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts index d7ce7b062..8e2f00df8 100644 --- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts +++ b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts @@ -2,23 +2,40 @@ import { match } from 'ts-pattern'; import type { Stripe } from '@documenso/lib/server-only/stripe'; import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; import { SubscriptionStatus } from '@documenso/prisma/client'; export type OnSubscriptionUpdatedOptions = { - userId: number; + userId?: number; + teamId?: number; subscription: Stripe.Subscription; }; export const onSubscriptionUpdated = async ({ userId, + teamId, subscription, }: OnSubscriptionUpdatedOptions) => { + await prisma.subscription.upsert( + mapStripeSubscriptionToPrismaUpsertAction(subscription, userId, teamId), + ); +}; + +export const mapStripeSubscriptionToPrismaUpsertAction = ( + subscription: Stripe.Subscription, + userId?: number, + teamId?: number, +): Prisma.SubscriptionUpsertArgs => { + if ((!userId && !teamId) || (userId && teamId)) { + throw new Error('Either userId or teamId must be provided.'); + } + const status = match(subscription.status) .with('active', () => SubscriptionStatus.ACTIVE) .with('past_due', () => SubscriptionStatus.PAST_DUE) .otherwise(() => SubscriptionStatus.INACTIVE); - await prisma.subscription.upsert({ + return { where: { planId: subscription.id, }, @@ -27,7 +44,8 @@ export const onSubscriptionUpdated = async ({ planId: subscription.id, priceId: subscription.items.data[0].price.id, periodEnd: new Date(subscription.current_period_end * 1000), - userId, + userId: userId ?? null, + teamId: teamId ?? null, cancelAtPeriodEnd: subscription.cancel_at_period_end, }, update: { @@ -37,5 +55,5 @@ export const onSubscriptionUpdated = async ({ periodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, }, - }); + }; }; diff --git a/packages/email/static/add-user.png b/packages/email/static/add-user.png new file mode 100644 index 000000000..abd337ceb Binary files /dev/null and b/packages/email/static/add-user.png differ diff --git a/packages/email/static/mail-open-alert.png b/packages/email/static/mail-open-alert.png new file mode 100644 index 000000000..1511f0bc5 Binary files /dev/null and b/packages/email/static/mail-open-alert.png differ diff --git a/packages/email/static/mail-open.png b/packages/email/static/mail-open.png new file mode 100644 index 000000000..306313b03 Binary files /dev/null and b/packages/email/static/mail-open.png differ diff --git a/packages/email/template-components/template-image.tsx b/packages/email/template-components/template-image.tsx new file mode 100644 index 000000000..8f821c10f --- /dev/null +++ b/packages/email/template-components/template-image.tsx @@ -0,0 +1,17 @@ +import { Img } from '../components'; + +export interface TemplateImageProps { + assetBaseUrl: string; + className?: string; + staticAsset: string; +} + +export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ; +}; + +export default TemplateImage; diff --git a/packages/email/templates/confirm-email.tsx b/packages/email/templates/confirm-email.tsx index b3acd1ecd..59c7add10 100644 --- a/packages/email/templates/confirm-email.tsx +++ b/packages/email/templates/confirm-email.tsx @@ -7,7 +7,7 @@ import { TemplateFooter } from '../template-components/template-footer'; export const ConfirmEmailTemplate = ({ confirmationLink, - assetBaseUrl, + assetBaseUrl = 'http://localhost:3002', }: TemplateConfirmationEmailProps) => { const previewText = `Please confirm your email address`; @@ -55,3 +55,5 @@ export const ConfirmEmailTemplate = ({ ); }; + +export default ConfirmEmailTemplate; diff --git a/packages/email/templates/confirm-team-email.tsx b/packages/email/templates/confirm-team-email.tsx new file mode 100644 index 000000000..5752f806d --- /dev/null +++ b/packages/email/templates/confirm-team-email.tsx @@ -0,0 +1,127 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type ConfirmTeamEmailProps = { + assetBaseUrl: string; + baseUrl: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const ConfirmTeamEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: ConfirmTeamEmailProps) => { + const previewText = `Accept team email request for ${teamName} on Documenso`; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + Verify your team email address + + + + {teamName} has requested to use your email + address for their team on Documenso. + + +
+ {formatTeamUrl(teamUrl, baseUrl)} +
+ +
+ + By accepting this request, you will be granting {teamName}{' '} + access to: + + +
    +
  • + View all documents sent to and from this email address +
  • +
  • + Allow document recipients to reply directly to this email address +
  • +
+ + + You can revoke access at any time in your team settings on Documenso{' '} + here. + +
+ +
+ +
+
+ + Link expires in 1 hour. +
+ +
+ + + + +
+ +
+ + ); +}; + +export default ConfirmTeamEmailTemplate; diff --git a/packages/email/templates/team-email-removed.tsx b/packages/email/templates/team-email-removed.tsx new file mode 100644 index 000000000..0a143d1b9 --- /dev/null +++ b/packages/email/templates/team-email-removed.tsx @@ -0,0 +1,83 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { Body, Container, Head, Hr, Html, Preview, Section, Tailwind, Text } from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamEmailRemovedTemplateProps = { + assetBaseUrl: string; + baseUrl: string; + teamEmail: string; + teamName: string; + teamUrl: string; +}; + +export const TeamEmailRemovedTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + teamEmail = 'example@documenso.com', + teamName = 'Team Name', + teamUrl = 'demo', +}: TeamEmailRemovedTemplateProps) => { + const previewText = `Team email removed for ${teamName} on Documenso`; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + Team email removed + + + + The team email {teamEmail} has been removed + from the following team + + +
+ {formatTeamUrl(teamUrl, baseUrl)} +
+
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default TeamEmailRemovedTemplate; diff --git a/packages/email/templates/team-invite.tsx b/packages/email/templates/team-invite.tsx new file mode 100644 index 000000000..4602b7382 --- /dev/null +++ b/packages/email/templates/team-invite.tsx @@ -0,0 +1,108 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamInviteEmailProps = { + assetBaseUrl: string; + baseUrl: string; + senderName: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const TeamInviteEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + senderName = 'John Doe', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: TeamInviteEmailProps) => { + const previewText = `Accept invitation to join a team on Documenso`; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + Join {teamName} on Documenso + + + + You have been invited to join the following team + + +
+ {formatTeamUrl(teamUrl, baseUrl)} +
+ + + by {senderName} + + +
+ +
+
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default TeamInviteEmailTemplate; diff --git a/packages/email/templates/team-transfer-request.tsx b/packages/email/templates/team-transfer-request.tsx new file mode 100644 index 000000000..82723226c --- /dev/null +++ b/packages/email/templates/team-transfer-request.tsx @@ -0,0 +1,112 @@ +import { formatTeamUrl } from '@documenso/lib/utils/teams'; +import config from '@documenso/tailwind-config'; + +import { + Body, + Button, + Container, + Head, + Hr, + Html, + Preview, + Section, + Tailwind, + Text, +} from '../components'; +import { TemplateFooter } from '../template-components/template-footer'; +import TemplateImage from '../template-components/template-image'; + +export type TeamTransferRequestTemplateProps = { + assetBaseUrl: string; + baseUrl: string; + senderName: string; + teamName: string; + teamUrl: string; + token: string; +}; + +export const TeamTransferRequestTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + senderName = 'John Doe', + teamName = 'Team Name', + teamUrl = 'demo', + token = '', +}: TeamTransferRequestTemplateProps) => { + const previewText = 'Accept team transfer request on Documenso'; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + {teamName} ownership transfer request + + + + {senderName} has requested that you take + ownership of the following team + + +
+ {formatTeamUrl(teamUrl, baseUrl)} +
+ + + By accepting this request, you will take responsibility for any billing items + associated with this team. + + +
+ +
+
+ + Link expires in 1 hour. +
+ +
+ + + + +
+ +
+ + ); +}; + +export default TeamTransferRequestTemplate; diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index a19d2bb0d..6c4d056d0 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -1,5 +1,9 @@ export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing'; export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web'; +export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true'; + +export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = + Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50; export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web'; @@ -7,5 +11,6 @@ export const APP_BASE_URL = IS_APP_WEB ? process.env.NEXT_PUBLIC_WEBAPP_URL : process.env.NEXT_PUBLIC_MARKETING_URL; -export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = - Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50; +export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'; + +export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001'; diff --git a/packages/lib/constants/billing.ts b/packages/lib/constants/billing.ts new file mode 100644 index 000000000..e6d897af8 --- /dev/null +++ b/packages/lib/constants/billing.ts @@ -0,0 +1,11 @@ +export enum STRIPE_CUSTOMER_TYPE { + INDIVIDUAL = 'individual', + TEAM = 'team', +} + +export enum STRIPE_PLAN_TYPE { + TEAM = 'team', + COMMUNITY = 'community', +} + +export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com'; diff --git a/packages/lib/constants/teams.ts b/packages/lib/constants/teams.ts new file mode 100644 index 000000000..47705bb14 --- /dev/null +++ b/packages/lib/constants/teams.ts @@ -0,0 +1,102 @@ +import { TeamMemberRole } from '@documenso/prisma/client'; + +export const TEAM_URL_ROOT_REGEX = new RegExp('^/t/[^/]+$'); + +export const TEAM_MEMBER_ROLE_MAP: Record = { + ADMIN: 'Admin', + MANAGER: 'Manager', + MEMBER: 'Member', +}; + +export const TEAM_MEMBER_ROLE_PERMISSIONS_MAP = { + /** + * Includes permissions to: + * - Manage team members + * - Manage team settings, changing name, url, etc. + */ + MANAGE_TEAM: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER], + MANAGE_BILLING: [TeamMemberRole.ADMIN], + DELETE_TEAM_TRANSFER_REQUEST: [TeamMemberRole.ADMIN], +} satisfies Record; + +/** + * A hierarchy of team member roles to determine which role has higher permission than another. + */ +export const TEAM_MEMBER_ROLE_HIERARCHY = { + [TeamMemberRole.ADMIN]: [TeamMemberRole.ADMIN, TeamMemberRole.MANAGER, TeamMemberRole.MEMBER], + [TeamMemberRole.MANAGER]: [TeamMemberRole.MANAGER, TeamMemberRole.MEMBER], + [TeamMemberRole.MEMBER]: [TeamMemberRole.MEMBER], +} satisfies Record; + +export const PROTECTED_TEAM_URLS = [ + '403', + '404', + '500', + '502', + '503', + '504', + 'about', + 'account', + 'admin', + 'administrator', + 'api', + 'app', + 'archive', + 'auth', + 'backup', + 'config', + 'configure', + 'contact', + 'contact-us', + 'copyright', + 'crime', + 'criminal', + 'dashboard', + 'docs', + 'documenso', + 'documentation', + 'document', + 'documents', + 'error', + 'exploit', + 'exploitation', + 'exploiter', + 'feedback', + 'finance', + 'forgot-password', + 'fraud', + 'fraudulent', + 'hack', + 'hacker', + 'harassment', + 'help', + 'helpdesk', + 'illegal', + 'internal', + 'legal', + 'login', + 'logout', + 'maintenance', + 'malware', + 'newsletter', + 'policy', + 'privacy', + 'profile', + 'public', + 'reset-password', + 'scam', + 'scammer', + 'settings', + 'setup', + 'sign', + 'signin', + 'signout', + 'signup', + 'spam', + 'support', + 'system', + 'team', + 'terms', + 'virus', + 'webhook', +]; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts new file mode 100644 index 000000000..3337bab4c --- /dev/null +++ b/packages/lib/errors/app-error.ts @@ -0,0 +1,144 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { TRPCClientError } from '@documenso/trpc/client'; + +/** + * Generic application error codes. + */ +export enum AppErrorCode { + 'ALREADY_EXISTS' = 'AlreadyExists', + 'EXPIRED_CODE' = 'ExpiredCode', + 'INVALID_BODY' = 'InvalidBody', + 'INVALID_REQUEST' = 'InvalidRequest', + 'NOT_FOUND' = 'NotFound', + 'NOT_SETUP' = 'NotSetup', + 'UNAUTHORIZED' = 'Unauthorized', + 'UNKNOWN_ERROR' = 'UnknownError', + 'RETRY_EXCEPTION' = 'RetryException', + 'SCHEMA_FAILED' = 'SchemaFailed', + 'TOO_MANY_REQUESTS' = 'TooManyRequests', +} + +const genericErrorCodeToTrpcErrorCodeMap: Record = { + [AppErrorCode.ALREADY_EXISTS]: 'BAD_REQUEST', + [AppErrorCode.EXPIRED_CODE]: 'BAD_REQUEST', + [AppErrorCode.INVALID_BODY]: 'BAD_REQUEST', + [AppErrorCode.INVALID_REQUEST]: 'BAD_REQUEST', + [AppErrorCode.NOT_FOUND]: 'NOT_FOUND', + [AppErrorCode.NOT_SETUP]: 'BAD_REQUEST', + [AppErrorCode.UNAUTHORIZED]: 'UNAUTHORIZED', + [AppErrorCode.UNKNOWN_ERROR]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.RETRY_EXCEPTION]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.SCHEMA_FAILED]: 'INTERNAL_SERVER_ERROR', + [AppErrorCode.TOO_MANY_REQUESTS]: 'TOO_MANY_REQUESTS', +}; + +export const ZAppErrorJsonSchema = z.object({ + code: z.string(), + message: z.string().optional(), + userMessage: z.string().optional(), +}); + +export type TAppErrorJsonSchema = z.infer; + +export class AppError extends Error { + /** + * The error code. + */ + code: string; + + /** + * An error message which can be displayed to the user. + */ + userMessage?: string; + + /** + * Create a new AppError. + * + * @param errorCode A string representing the error code. + * @param message An internal error message. + * @param userMessage A error message which can be displayed to the user. + */ + public constructor(errorCode: string, message?: string, userMessage?: string) { + super(message || errorCode); + this.code = errorCode; + this.userMessage = userMessage; + } + + /** + * Parse an unknown value into an AppError. + * + * @param error An unknown type. + */ + static parseError(error: unknown): AppError { + if (error instanceof AppError) { + return error; + } + + // Handle TRPC errors. + if (error instanceof TRPCClientError) { + const parsedJsonError = AppError.parseFromJSONString(error.message); + return parsedJsonError || new AppError('UnknownError', error.message); + } + + // Handle completely unknown errors. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const { code, message, userMessage } = error as { + code: unknown; + message: unknown; + status: unknown; + userMessage: unknown; + }; + + const validCode: string | null = typeof code === 'string' ? code : AppErrorCode.UNKNOWN_ERROR; + const validMessage: string | undefined = typeof message === 'string' ? message : undefined; + const validUserMessage: string | undefined = + typeof userMessage === 'string' ? userMessage : undefined; + + return new AppError(validCode, validMessage, validUserMessage); + } + + static parseErrorToTRPCError(error: unknown): TRPCError { + const appError = AppError.parseError(error); + + return new TRPCError({ + code: genericErrorCodeToTrpcErrorCodeMap[appError.code] || 'BAD_REQUEST', + message: AppError.toJSONString(appError), + }); + } + + /** + * Convert an AppError into a JSON object which represents the error. + * + * @param appError The AppError to convert to JSON. + * @returns A JSON object representing the AppError. + */ + static toJSON({ code, message, userMessage }: AppError): TAppErrorJsonSchema { + return { + code, + message, + userMessage, + }; + } + + /** + * Convert an AppError into a JSON string containing the relevant information. + * + * @param appError The AppError to stringify. + * @returns A JSON string representing the AppError. + */ + static toJSONString(appError: AppError): string { + return JSON.stringify(AppError.toJSON(appError)); + } + + static parseFromJSONString(jsonString: string): AppError | null { + const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString)); + + if (!parsed.success) { + return null; + } + + return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage); + } +} diff --git a/packages/lib/server-only/crypto/decrypt.ts b/packages/lib/server-only/crypto/decrypt.ts index 7b4db9894..de7b82c4b 100644 --- a/packages/lib/server-only/crypto/decrypt.ts +++ b/packages/lib/server-only/crypto/decrypt.ts @@ -13,21 +13,25 @@ export const decryptSecondaryData = (encryptedData: string): string | null => { throw new Error('Missing encryption key'); } - const decryptedBufferValue = symmetricDecrypt({ - key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, - data: encryptedData, - }); + try { + const decryptedBufferValue = symmetricDecrypt({ + key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY, + data: encryptedData, + }); - const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8'); - const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue)); + const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8'); + const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue)); - if (!result.success) { + if (!result.success) { + return null; + } + + if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) { + return null; + } + + return result.data.data; + } catch { return null; } - - if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) { - return null; - } - - return result.data.data; }; diff --git a/packages/lib/server-only/document-meta/upsert-document-meta.ts b/packages/lib/server-only/document-meta/upsert-document-meta.ts index b67c6848b..3e6cd75be 100644 --- a/packages/lib/server-only/document-meta/upsert-document-meta.ts +++ b/packages/lib/server-only/document-meta/upsert-document-meta.ts @@ -24,7 +24,20 @@ export const upsertDocumentMeta = async ({ await prisma.document.findFirstOrThrow({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/document/create-document.ts b/packages/lib/server-only/document/create-document.ts index b84f8e46e..93307a7b4 100644 --- a/packages/lib/server-only/document/create-document.ts +++ b/packages/lib/server-only/document/create-document.ts @@ -5,15 +5,37 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentOptions = { title: string; userId: number; + teamId?: number; documentDataId: string; }; -export const createDocument = async ({ userId, title, documentDataId }: CreateDocumentOptions) => { - return await prisma.document.create({ - data: { - title, - documentDataId, - userId, - }, +export const createDocument = async ({ + userId, + title, + documentDataId, + teamId, +}: CreateDocumentOptions) => { + return await prisma.$transaction(async (tx) => { + if (teamId !== undefined) { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + }); + } + + return await tx.document.create({ + data: { + title, + documentDataId, + userId, + teamId, + }, + }); }); }; diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts index ddb70b1cb..5ca848bb3 100644 --- a/packages/lib/server-only/document/duplicate-document-by-id.ts +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -1,16 +1,27 @@ import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; + +import { getDocumentWhereInput } from './get-document-by-id'; export interface DuplicateDocumentByIdOptions { id: number; userId: number; + teamId?: number; } -export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByIdOptions) => { +export const duplicateDocumentById = async ({ + id, + userId, + teamId, +}: DuplicateDocumentByIdOptions) => { + const documentWhereInput = await getDocumentWhereInput({ + documentId: id, + userId, + teamId, + }); + const document = await prisma.document.findUniqueOrThrow({ - where: { - id, - userId: userId, - }, + where: documentWhereInput, select: { title: true, userId: true, @@ -33,7 +44,7 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI }, }); - const createdDocument = await prisma.document.create({ + const createDocumentArguments: Prisma.DocumentCreateArgs = { data: { title: document.title, User: { @@ -53,7 +64,17 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI }, }, }, - }); + }; + + if (teamId !== undefined) { + createDocumentArguments.data.team = { + connect: { + id: teamId, + }, + }; + } + + const createdDocument = await prisma.document.create(createDocumentArguments); return createdDocument.id; }; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 8d367dbe4..f34cc4c2c 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -2,8 +2,8 @@ import { DateTime } from 'luxon'; import { P, match } from 'ts-pattern'; import { prisma } from '@documenso/prisma'; -import type { Document, Prisma } from '@documenso/prisma/client'; import { RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import type { Document, Prisma, Team, TeamEmail, User } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { FindResultSet } from '../../types/find-result-set'; @@ -13,6 +13,7 @@ export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; export type FindDocumentsOptions = { userId: number; + teamId?: number; term?: string; status?: ExtendedDocumentStatus; page?: number; @@ -22,21 +23,49 @@ export type FindDocumentsOptions = { direction: 'asc' | 'desc'; }; period?: PeriodSelectorValue; + senderIds?: number[]; }; export const findDocuments = async ({ userId, + teamId, term, status = ExtendedDocumentStatus.ALL, page = 1, perPage = 10, orderBy, period, + senderIds, }: FindDocumentsOptions) => { - const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, + const { user, team } = await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + let team = null; + + if (teamId !== undefined) { + team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + include: { + teamEmail: true, + }, + }); + } + + return { + user, + team, + }; }); const orderByColumn = orderBy?.column ?? 'createdAt'; @@ -53,96 +82,34 @@ export const findDocuments = async ({ }) .otherwise(() => undefined); - const filters = match(status) - .with(ExtendedDocumentStatus.ALL, () => ({ - OR: [ - { - userId, - deletedAt: null, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - Recipient: { - some: { - email: user.email, - }, - }, - }, - { - status: ExtendedDocumentStatus.PENDING, - Recipient: { - some: { - email: user.email, - }, - }, - deletedAt: null, - }, - ], - })) - .with(ExtendedDocumentStatus.INBOX, () => ({ - status: { - not: ExtendedDocumentStatus.DRAFT, - }, - Recipient: { - some: { - email: user.email, - signingStatus: SigningStatus.NOT_SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - deletedAt: null, - })) - .with(ExtendedDocumentStatus.DRAFT, () => ({ - userId, - status: ExtendedDocumentStatus.DRAFT, - deletedAt: null, - })) - .with(ExtendedDocumentStatus.PENDING, () => ({ - OR: [ - { - userId, - status: ExtendedDocumentStatus.PENDING, - deletedAt: null, - }, - { - status: ExtendedDocumentStatus.PENDING, - Recipient: { - some: { - email: user.email, - signingStatus: SigningStatus.SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - deletedAt: null, - }, - ], - })) - .with(ExtendedDocumentStatus.COMPLETED, () => ({ - OR: [ - { - userId, - status: ExtendedDocumentStatus.COMPLETED, - deletedAt: null, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - Recipient: { - some: { - email: user.email, - }, - }, - }, - ], - })) - .exhaustive(); + const filters = team ? findTeamDocumentsFilter(status, team) : findDocumentsFilter(status, user); - const whereClause = { + if (filters === null) { + return { + data: [], + count: 0, + currentPage: 1, + perPage, + totalPages: 0, + }; + } + + const whereClause: Prisma.DocumentWhereInput = { ...termFilters, ...filters, + AND: { + OR: [ + { + status: ExtendedDocumentStatus.COMPLETED, + }, + { + status: { + not: ExtendedDocumentStatus.COMPLETED, + }, + deletedAt: null, + }, + ], + }, }; if (period) { @@ -155,6 +122,12 @@ export const findDocuments = async ({ }; } + if (senderIds && senderIds.length > 0) { + whereClause.userId = { + in: senderIds, + }; + } + const [data, count] = await Promise.all([ prisma.document.findMany({ where: whereClause, @@ -172,13 +145,16 @@ export const findDocuments = async ({ }, }, Recipient: true, + team: { + select: { + id: true, + url: true, + }, + }, }, }), prisma.document.count({ - where: { - ...termFilters, - ...filters, - }, + where: whereClause, }), ]); @@ -197,3 +173,268 @@ export const findDocuments = async ({ totalPages: Math.ceil(count / perPage), } satisfies FindResultSet; }; + +const findDocumentsFilter = (status: ExtendedDocumentStatus, user: User) => { + return match(status) + .with(ExtendedDocumentStatus.ALL, () => ({ + OR: [ + { + userId: user.id, + teamId: null, + }, + { + status: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: user.email, + }, + }, + }, + { + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: user.email, + }, + }, + }, + ], + })) + .with(ExtendedDocumentStatus.INBOX, () => ({ + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: user.email, + signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + })) + .with(ExtendedDocumentStatus.DRAFT, () => ({ + userId: user.id, + teamId: null, + status: ExtendedDocumentStatus.DRAFT, + })) + .with(ExtendedDocumentStatus.PENDING, () => ({ + OR: [ + { + userId: user.id, + teamId: null, + status: ExtendedDocumentStatus.PENDING, + }, + { + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: user.email, + signingStatus: SigningStatus.SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + }, + ], + })) + .with(ExtendedDocumentStatus.COMPLETED, () => ({ + OR: [ + { + userId: user.id, + teamId: null, + status: ExtendedDocumentStatus.COMPLETED, + }, + { + status: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: user.email, + }, + }, + }, + ], + })) + .exhaustive(); +}; + +/** + * Create a Prisma filter for the Document schema to find documents for a team. + * + * Status All: + * - Documents that belong to the team + * - Documents that have been sent by the team email + * - Non draft documents that have been sent to the team email + * + * Status Inbox: + * - Non draft documents that have been sent to the team email that have not been signed + * + * Status Draft: + * - Documents that belong to the team that are draft + * - Documents that belong to the team email that are draft + * + * Status Pending: + * - Documents that belong to the team that are pending + * - Documents that have been sent by the team email that is pending to be signed by someone else + * - Documents that have been sent to the team email that is pending to be signed by someone else + * + * Status Completed: + * - Documents that belong to the team that are completed + * - Documents that have been sent to the team email that are completed + * - Documents that have been sent by the team email that are completed + * + * @param status The status of the documents to find. + * @param team The team to find the documents for. + * @returns A filter which can be applied to the Prisma Document schema. + */ +const findTeamDocumentsFilter = ( + status: ExtendedDocumentStatus, + team: Team & { teamEmail: TeamEmail | null }, +) => { + const teamEmail = team.teamEmail?.email ?? null; + + return match(status) + .with(ExtendedDocumentStatus.ALL, () => { + const filter: Prisma.DocumentWhereInput = { + // Filter to display all documents that belong to the team. + OR: [ + { + teamId: team.id, + }, + ], + }; + + if (teamEmail && filter.OR) { + // Filter to display all documents received by the team email that are not draft. + filter.OR.push({ + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: teamEmail, + }, + }, + }); + + // Filter to display all documents that have been sent by the team email. + filter.OR.push({ + User: { + email: teamEmail, + }, + }); + } + + return filter; + }) + .with(ExtendedDocumentStatus.INBOX, () => { + // Return a filter that will return nothing. + if (!teamEmail) { + return null; + } + + return { + status: { + not: ExtendedDocumentStatus.DRAFT, + }, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.NOT_SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + }; + }) + .with(ExtendedDocumentStatus.DRAFT, () => { + const filter: Prisma.DocumentWhereInput = { + OR: [ + { + teamId: team.id, + status: ExtendedDocumentStatus.DRAFT, + }, + ], + }; + + if (teamEmail && filter.OR) { + filter.OR.push({ + status: ExtendedDocumentStatus.DRAFT, + User: { + email: teamEmail, + }, + }); + } + + return filter; + }) + .with(ExtendedDocumentStatus.PENDING, () => { + const filter: Prisma.DocumentWhereInput = { + OR: [ + { + teamId: team.id, + status: ExtendedDocumentStatus.PENDING, + }, + ], + }; + + if (teamEmail && filter.OR) { + filter.OR.push({ + status: ExtendedDocumentStatus.PENDING, + OR: [ + { + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.SIGNED, + role: { + not: RecipientRole.CC, + }, + }, + }, + }, + { + User: { + email: teamEmail, + }, + }, + ], + }); + } + + return filter; + }) + .with(ExtendedDocumentStatus.COMPLETED, () => { + const filter: Prisma.DocumentWhereInput = { + status: ExtendedDocumentStatus.COMPLETED, + OR: [ + { + teamId: team.id, + }, + ], + }; + + if (teamEmail && filter.OR) { + filter.OR.push( + { + Recipient: { + some: { + email: teamEmail, + }, + }, + }, + { + User: { + email: teamEmail, + }, + }, + ); + } + + return filter; + }) + .exhaustive(); +}; diff --git a/packages/lib/server-only/document/get-document-by-id.ts b/packages/lib/server-only/document/get-document-by-id.ts index 0b599a71c..71b614976 100644 --- a/packages/lib/server-only/document/get-document-by-id.ts +++ b/packages/lib/server-only/document/get-document-by-id.ts @@ -1,19 +1,106 @@ import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; -export interface GetDocumentByIdOptions { +import { getTeamById } from '../team/get-team'; + +export type GetDocumentByIdOptions = { id: number; userId: number; -} + teamId?: number; +}; + +export const getDocumentById = async ({ id, userId, teamId }: GetDocumentByIdOptions) => { + const documentWhereInput = await getDocumentWhereInput({ + documentId: id, + userId, + teamId, + }); -export const getDocumentById = async ({ id, userId }: GetDocumentByIdOptions) => { return await prisma.document.findFirstOrThrow({ - where: { - id, - userId, - }, + where: documentWhereInput, include: { documentData: true, documentMeta: true, }, }); }; + +export type GetDocumentWhereInputOptions = { + documentId: number; + userId: number; + teamId?: number; + + /** + * Whether to return a filter that allows access to both the user and team documents. + * This only applies if `teamId` is passed in. + * + * If true, and `teamId` is passed in, the filter will allow both team and user documents. + * If false, and `teamId` is passed in, the filter will only allow team documents. + * + * Defaults to false. + */ + overlapUserTeamScope?: boolean; +}; + +/** + * Generate the where input for a given Prisma document query. + * + * This will return a query that allows a user to get a document if they have valid access to it. + */ +export const getDocumentWhereInput = async ({ + documentId, + userId, + teamId, + overlapUserTeamScope = false, +}: GetDocumentWhereInputOptions) => { + const documentWhereInput: Prisma.DocumentWhereUniqueInput = { + id: documentId, + OR: [ + { + userId, + }, + ], + }; + + if (teamId === undefined || !documentWhereInput.OR) { + return documentWhereInput; + } + + const team = await getTeamById({ teamId, userId }); + + // Allow access to team and user documents. + if (overlapUserTeamScope) { + documentWhereInput.OR.push({ + teamId: team.id, + }); + } + + // Allow access to only team documents. + if (!overlapUserTeamScope) { + documentWhereInput.OR = [ + { + teamId: team.id, + }, + ]; + } + + // Allow access to documents sent to or from the team email. + if (team.teamEmail) { + documentWhereInput.OR.push( + { + Recipient: { + some: { + email: team.teamEmail.email, + }, + }, + }, + { + User: { + email: team.teamEmail.email, + }, + }, + ); + } + + return documentWhereInput; +}; diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 6aaa9a596..db38fa79d 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,19 +1,19 @@ import { DateTime } from 'luxon'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; import { prisma } from '@documenso/prisma'; import type { Prisma, User } from '@documenso/prisma/client'; import { SigningStatus } from '@documenso/prisma/client'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; -import type { PeriodSelectorValue } from './find-documents'; - export type GetStatsInput = { user: User; + team?: Omit; period?: PeriodSelectorValue; }; -export const getStats = async ({ user, period }: GetStatsInput) => { +export const getStats = async ({ user, period, ...options }: GetStatsInput) => { let createdAt: Prisma.DocumentWhereInput['createdAt']; if (period) { @@ -26,7 +26,52 @@ export const getStats = async ({ user, period }: GetStatsInput) => { }; } - const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([ + const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team + ? getTeamCounts({ ...options.team, createdAt }) + : getCounts({ user, createdAt })); + + const stats: Record = { + [ExtendedDocumentStatus.DRAFT]: 0, + [ExtendedDocumentStatus.PENDING]: 0, + [ExtendedDocumentStatus.COMPLETED]: 0, + [ExtendedDocumentStatus.INBOX]: 0, + [ExtendedDocumentStatus.ALL]: 0, + }; + + ownerCounts.forEach((stat) => { + stats[stat.status] = stat._count._all; + }); + + notSignedCounts.forEach((stat) => { + stats[ExtendedDocumentStatus.INBOX] += stat._count._all; + }); + + hasSignedCounts.forEach((stat) => { + if (stat.status === ExtendedDocumentStatus.COMPLETED) { + stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; + } + + if (stat.status === ExtendedDocumentStatus.PENDING) { + stats[ExtendedDocumentStatus.PENDING] += stat._count._all; + } + }); + + Object.keys(stats).forEach((key) => { + if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { + stats[ExtendedDocumentStatus.ALL] += stats[key]; + } + }); + + return stats; +}; + +type GetCountsOption = { + user: User; + createdAt: Prisma.DocumentWhereInput['createdAt']; +}; + +const getCounts = async ({ user, createdAt }: GetCountsOption) => { + return Promise.all([ prisma.document.groupBy({ by: ['status'], _count: { @@ -35,6 +80,7 @@ export const getStats = async ({ user, period }: GetStatsInput) => { where: { userId: user.id, createdAt, + teamId: null, deletedAt: null, }, }), @@ -91,38 +137,116 @@ export const getStats = async ({ user, period }: GetStatsInput) => { }, }), ]); +}; - const stats: Record = { - [ExtendedDocumentStatus.DRAFT]: 0, - [ExtendedDocumentStatus.PENDING]: 0, - [ExtendedDocumentStatus.COMPLETED]: 0, - [ExtendedDocumentStatus.INBOX]: 0, - [ExtendedDocumentStatus.ALL]: 0, +type GetTeamCountsOption = { + teamId: number; + teamEmail?: string; + senderIds?: number[]; + createdAt: Prisma.DocumentWhereInput['createdAt']; +}; + +const getTeamCounts = async (options: GetTeamCountsOption) => { + const { createdAt, teamId, teamEmail } = options; + + const senderIds = options.senderIds ?? []; + + const userIdWhereClause: Prisma.DocumentWhereInput['userId'] = + senderIds.length > 0 + ? { + in: senderIds, + } + : undefined; + + let ownerCountsWhereInput: Prisma.DocumentWhereInput = { + userId: userIdWhereClause, + createdAt, + teamId, + deletedAt: null, }; - ownerCounts.forEach((stat) => { - stats[stat.status] = stat._count._all; - }); + let notSignedCountsGroupByArgs = null; + let hasSignedCountsGroupByArgs = null; - notSignedCounts.forEach((stat) => { - stats[ExtendedDocumentStatus.INBOX] += stat._count._all; - }); + if (teamEmail) { + ownerCountsWhereInput = { + userId: userIdWhereClause, + createdAt, + OR: [ + { + teamId, + }, + { + User: { + email: teamEmail, + }, + }, + ], + deletedAt: null, + }; - hasSignedCounts.forEach((stat) => { - if (stat.status === ExtendedDocumentStatus.COMPLETED) { - stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; - } + notSignedCountsGroupByArgs = { + by: ['status'], + _count: { + _all: true, + }, + where: { + userId: userIdWhereClause, + createdAt, + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.NOT_SIGNED, + }, + }, + deletedAt: null, + }, + } satisfies Prisma.DocumentGroupByArgs; - if (stat.status === ExtendedDocumentStatus.PENDING) { - stats[ExtendedDocumentStatus.PENDING] += stat._count._all; - } - }); + hasSignedCountsGroupByArgs = { + by: ['status'], + _count: { + _all: true, + }, + where: { + userId: userIdWhereClause, + createdAt, + OR: [ + { + status: ExtendedDocumentStatus.PENDING, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.SIGNED, + }, + }, + deletedAt: null, + }, + { + status: ExtendedDocumentStatus.COMPLETED, + Recipient: { + some: { + email: teamEmail, + signingStatus: SigningStatus.SIGNED, + }, + }, + deletedAt: null, + }, + ], + }, + } satisfies Prisma.DocumentGroupByArgs; + } - Object.keys(stats).forEach((key) => { - if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { - stats[ExtendedDocumentStatus.ALL] += stats[key]; - } - }); - - return stats; + return Promise.all([ + prisma.document.groupBy({ + by: ['status'], + _count: { + _all: true, + }, + where: ownerCountsWhereInput, + }), + notSignedCountsGroupByArgs ? prisma.document.groupBy(notSignedCountsGroupByArgs) : [], + hasSignedCountsGroupByArgs ? prisma.document.groupBy(hasSignedCountsGroupByArgs) : [], + ]); }; diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx index 4c7b66be8..d72da3a8d 100644 --- a/packages/lib/server-only/document/resend-document.tsx +++ b/packages/lib/server-only/document/resend-document.tsx @@ -7,27 +7,38 @@ import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { prisma } from '@documenso/prisma'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; +import type { Prisma } from '@documenso/prisma/client'; import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles'; +import { getDocumentWhereInput } from './get-document-by-id'; export type ResendDocumentOptions = { documentId: number; userId: number; recipients: number[]; + teamId?: number; }; -export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => { +export const resendDocument = async ({ + documentId, + userId, + recipients, + teamId, +}: ResendDocumentOptions) => { const user = await prisma.user.findFirstOrThrow({ where: { id: userId, }, }); + const documentWhereInput: Prisma.DocumentWhereUniqueInput = await getDocumentWhereInput({ + documentId, + userId, + teamId, + }); + const document = await prisma.document.findUnique({ - where: { - id: documentId, - userId, - }, + where: documentWhereInput, include: { Recipient: { where: { diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx index 82b37852b..312b30462 100644 --- a/packages/lib/server-only/document/send-document.tsx +++ b/packages/lib/server-only/document/send-document.tsx @@ -25,7 +25,20 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) const document = await prisma.document.findUnique({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, include: { Recipient: true, diff --git a/packages/lib/server-only/document/update-title.ts b/packages/lib/server-only/document/update-title.ts index ba086b9cb..19a902930 100644 --- a/packages/lib/server-only/document/update-title.ts +++ b/packages/lib/server-only/document/update-title.ts @@ -12,7 +12,20 @@ export const updateTitle = async ({ userId, documentId, title }: UpdateTitleOpti return await prisma.document.update({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, data: { title, diff --git a/packages/lib/server-only/field/get-fields-for-document.ts b/packages/lib/server-only/field/get-fields-for-document.ts index ddc35b503..72a16c3f7 100644 --- a/packages/lib/server-only/field/get-fields-for-document.ts +++ b/packages/lib/server-only/field/get-fields-for-document.ts @@ -10,7 +10,20 @@ export const getFieldsForDocument = async ({ documentId, userId }: GetFieldsForD where: { documentId, Document: { - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }, orderBy: { diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index bd14d49b2..2ba592f31 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -25,7 +25,20 @@ export const setFieldsForDocument = async ({ const document = await prisma.document.findFirst({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/recipient/get-recipients-for-document.ts b/packages/lib/server-only/recipient/get-recipients-for-document.ts index 21d198d3e..80e408acc 100644 --- a/packages/lib/server-only/recipient/get-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/get-recipients-for-document.ts @@ -13,7 +13,20 @@ export const getRecipientsForDocument = async ({ where: { documentId, Document: { - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }, orderBy: { diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts index 4917b213d..d42d1d707 100644 --- a/packages/lib/server-only/recipient/set-recipients-for-document.ts +++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts @@ -23,7 +23,20 @@ export const setRecipientsForDocument = async ({ const document = await prisma.document.findFirst({ where: { id: documentId, - userId, + OR: [ + { + userId, + }, + { + team: { + members: { + some: { + userId, + }, + }, + }, + }, + ], }, }); diff --git a/packages/lib/server-only/team/accept-team-invitation.ts b/packages/lib/server-only/team/accept-team-invitation.ts new file mode 100644 index 000000000..a69a79ecd --- /dev/null +++ b/packages/lib/server-only/team/accept-team-invitation.ts @@ -0,0 +1,63 @@ +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { prisma } from '@documenso/prisma'; + +import { IS_BILLING_ENABLED } from '../../constants/app'; + +export type AcceptTeamInvitationOptions = { + userId: number; + teamId: number; +}; + +export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const teamMemberInvite = await tx.teamMemberInvite.findFirstOrThrow({ + where: { + teamId, + email: user.email, + }, + include: { + team: { + include: { + subscription: true, + }, + }, + }, + }); + + const { team } = teamMemberInvite; + + await tx.teamMember.create({ + data: { + teamId: teamMemberInvite.teamId, + userId: user.id, + role: teamMemberInvite.role, + }, + }); + + await tx.teamMemberInvite.delete({ + where: { + id: teamMemberInvite.id, + }, + }); + + if (IS_BILLING_ENABLED && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId: teamMemberInvite.teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }); +}; diff --git a/packages/lib/server-only/team/create-team-billing-portal.ts b/packages/lib/server-only/team/create-team-billing-portal.ts new file mode 100644 index 000000000..d394f2720 --- /dev/null +++ b/packages/lib/server-only/team/create-team-billing-portal.ts @@ -0,0 +1,47 @@ +import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { prisma } from '@documenso/prisma'; + +export type CreateTeamBillingPortalOptions = { + userId: number; + teamId: number; +}; + +export const createTeamBillingPortal = async ({ + userId, + teamId, +}: CreateTeamBillingPortalOptions) => { + if (!IS_BILLING_ENABLED) { + throw new Error('Billing is not enabled'); + } + + const team = await prisma.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_BILLING'], + }, + }, + }, + }, + include: { + subscription: true, + }, + }); + + if (!team.subscription) { + throw new Error('Team has no subscription'); + } + + if (!team.customerId) { + throw new Error('Team has no customerId'); + } + + return getPortalSession({ + customerId: team.customerId, + }); +}; diff --git a/packages/lib/server-only/team/create-team-checkout-session.ts b/packages/lib/server-only/team/create-team-checkout-session.ts new file mode 100644 index 000000000..b80fc260b --- /dev/null +++ b/packages/lib/server-only/team/create-team-checkout-session.ts @@ -0,0 +1,52 @@ +import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; +import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +export type CreateTeamPendingCheckoutSession = { + userId: number; + pendingTeamId: number; + interval: 'monthly' | 'yearly'; +}; + +export const createTeamPendingCheckoutSession = async ({ + userId, + pendingTeamId, + interval, +}: CreateTeamPendingCheckoutSession) => { + const teamPendingCreation = await prisma.teamPending.findFirstOrThrow({ + where: { + id: pendingTeamId, + ownerUserId: userId, + }, + include: { + owner: true, + }, + }); + + const prices = await getTeamPrices(); + const priceId = prices[interval].priceId; + + try { + const stripeCheckoutSession = await getCheckoutSession({ + customerId: teamPendingCreation.customerId, + priceId, + returnUrl: `${WEBAPP_BASE_URL}/settings/teams`, + subscriptionMetadata: { + pendingTeamId: pendingTeamId.toString(), + }, + }); + + if (!stripeCheckoutSession) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR); + } + + return stripeCheckoutSession; + } catch (e) { + console.error(e); + + // Absorb all the errors incase Stripe throws something sensitive. + throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Something went wrong.'); + } +}; diff --git a/packages/lib/server-only/team/create-team-email-verification.ts b/packages/lib/server-only/team/create-team-email-verification.ts new file mode 100644 index 000000000..28e1538d0 --- /dev/null +++ b/packages/lib/server-only/team/create-team-email-verification.ts @@ -0,0 +1,132 @@ +import { createElement } from 'react'; + +import { z } from 'zod'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { ConfirmTeamEmailTemplate } from '@documenso/email/templates/confirm-team-email'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { createTokenVerification } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +export type CreateTeamEmailVerificationOptions = { + userId: number; + teamId: number; + data: { + email: string; + name: string; + }; +}; + +export const createTeamEmailVerification = async ({ + userId, + teamId, + data, +}: CreateTeamEmailVerificationOptions) => { + try { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + teamEmail: true, + emailVerification: true, + }, + }); + + if (team.teamEmail || team.emailVerification) { + throw new AppError( + AppErrorCode.INVALID_REQUEST, + 'Team already has an email or existing email verification.', + ); + } + + const existingTeamEmail = await tx.teamEmail.findFirst({ + where: { + email: data.email, + }, + }); + + if (existingTeamEmail) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); + } + + const { token, expiresAt } = createTokenVerification({ hours: 1 }); + + await tx.teamEmailVerification.create({ + data: { + token, + expiresAt, + email: data.email, + name: data.name, + teamId, + }, + }); + + await sendTeamEmailVerificationEmail(data.email, token, team.name, team.url); + }); + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('email')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Email already taken by another team.'); + } + + throw err; + } +}; + +/** + * Send an email to a user asking them to accept a team email request. + * + * @param email The email address to use for the team. + * @param token The token used to authenticate that the user has granted access. + * @param teamName The name of the team the user is being invited to. + * @param teamUrl The url of the team the user is being invited to. + */ +export const sendTeamEmailVerificationEmail = async ( + email: string, + token: string, + teamName: string, + teamUrl: string, +) => { + const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; + + const template = createElement(ConfirmTeamEmailTemplate, { + assetBaseUrl, + baseUrl: WEBAPP_BASE_URL, + teamName, + teamUrl, + token, + }); + + await mailer.sendMail({ + to: email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `A request to use your email has been initiated by ${teamName} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); +}; diff --git a/packages/lib/server-only/team/create-team-member-invites.ts b/packages/lib/server-only/team/create-team-member-invites.ts new file mode 100644 index 000000000..f167d2112 --- /dev/null +++ b/packages/lib/server-only/team/create-team-member-invites.ts @@ -0,0 +1,161 @@ +import { createElement } from 'react'; + +import { nanoid } from 'nanoid'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import type { TeamInviteEmailProps } from '@documenso/email/templates/team-invite'; +import { TeamInviteEmailTemplate } from '@documenso/email/templates/team-invite'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberInviteStatus } from '@documenso/prisma/client'; +import type { TCreateTeamMemberInvitesMutationSchema } from '@documenso/trpc/server/team-router/schema'; + +export type CreateTeamMemberInvitesOptions = { + userId: number; + userName: string; + teamId: number; + invitations: TCreateTeamMemberInvitesMutationSchema['invitations']; +}; + +/** + * Invite team members via email to join a team. + */ +export const createTeamMemberInvites = async ({ + userId, + userName, + teamId, + invitations, +}: CreateTeamMemberInvitesOptions) => { + const team = await prisma.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + members: { + select: { + role: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + invites: true, + }, + }); + + const teamMemberEmails = team.members.map((member) => member.user.email); + const teamMemberInviteEmails = team.invites.map((invite) => invite.email); + const currentTeamMember = team.members.find((member) => member.user.id === userId); + + if (!currentTeamMember) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'User not part of team.'); + } + + const usersToInvite = invitations.filter((invitation) => { + // Filter out users that are already members of the team. + if (teamMemberEmails.includes(invitation.email)) { + return false; + } + + // Filter out users that have already been invited to the team. + if (teamMemberInviteEmails.includes(invitation.email)) { + return false; + } + + return true; + }); + + const unauthorizedRoleAccess = usersToInvite.some( + ({ role }) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, role), + ); + + if (unauthorizedRoleAccess) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'User does not have permission to set high level roles', + ); + } + + const teamMemberInvites = usersToInvite.map(({ email, role }) => ({ + email, + teamId, + role, + status: TeamMemberInviteStatus.PENDING, + token: nanoid(32), + })); + + await prisma.teamMemberInvite.createMany({ + data: teamMemberInvites, + }); + + const sendEmailResult = await Promise.allSettled( + teamMemberInvites.map(async ({ email, token }) => + sendTeamMemberInviteEmail({ + email, + token, + teamName: team.name, + teamUrl: team.url, + senderName: userName, + }), + ), + ); + + const sendEmailResultErrorList = sendEmailResult.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + + if (sendEmailResultErrorList.length > 0) { + console.error(JSON.stringify(sendEmailResultErrorList)); + + throw new AppError( + 'EmailDeliveryFailed', + 'Failed to send invite emails to one or more users.', + `Failed to send invites to ${sendEmailResultErrorList.length}/${teamMemberInvites.length} users.`, + ); + } +}; + +type SendTeamMemberInviteEmailOptions = Omit & { + email: string; +}; + +/** + * Send an email to a user inviting them to join a team. + */ +export const sendTeamMemberInviteEmail = async ({ + email, + ...emailTemplateOptions +}: SendTeamMemberInviteEmailOptions) => { + const template = createElement(TeamInviteEmailTemplate, { + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + ...emailTemplateOptions, + }); + + await mailer.sendMail({ + to: email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `You have been invited to join ${emailTemplateOptions.teamName} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); +}; diff --git a/packages/lib/server-only/team/create-team.ts b/packages/lib/server-only/team/create-team.ts new file mode 100644 index 000000000..f1d245523 --- /dev/null +++ b/packages/lib/server-only/team/create-team.ts @@ -0,0 +1,207 @@ +import type Stripe from 'stripe'; +import { z } from 'zod'; + +import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer'; +import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices'; +import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import { Prisma, TeamMemberRole } from '@documenso/prisma/client'; + +import { stripe } from '../stripe'; + +export type CreateTeamOptions = { + /** + * ID of the user creating the Team. + */ + userId: number; + + /** + * Name of the team to display. + */ + teamName: string; + + /** + * Unique URL of the team. + * + * Used as the URL path, example: https://documenso.com/t/{teamUrl}/settings + */ + teamUrl: string; +}; + +export type CreateTeamResponse = + | { + paymentRequired: false; + } + | { + paymentRequired: true; + pendingTeamId: number; + }; + +/** + * Create a team or pending team depending on the user's subscription or application's billing settings. + */ +export const createTeam = async ({ + userId, + teamName, + teamUrl, +}: CreateTeamOptions): Promise => { + const user = await prisma.user.findUniqueOrThrow({ + where: { + id: userId, + }, + include: { + Subscription: true, + }, + }); + + let isPaymentRequired = IS_BILLING_ENABLED; + let customerId: string | null = null; + + if (IS_BILLING_ENABLED) { + const communityPlanPriceIds = await getCommunityPlanPriceIds(); + + isPaymentRequired = !subscriptionsContainsActiveCommunityPlan( + user.Subscription, + communityPlanPriceIds, + ); + + customerId = await createTeamCustomer({ + name: user.name ?? teamName, + email: user.email, + }).then((customer) => customer.id); + } + + try { + // Create the team directly if no payment is required. + if (!isPaymentRequired) { + await prisma.team.create({ + data: { + name: teamName, + url: teamUrl, + ownerUserId: user.id, + customerId, + members: { + create: [ + { + userId, + role: TeamMemberRole.ADMIN, + }, + ], + }, + }, + }); + + return { + paymentRequired: false, + }; + } + + // Create a pending team if payment is required. + const pendingTeam = await prisma.$transaction(async (tx) => { + const existingTeamWithUrl = await tx.team.findUnique({ + where: { + url: teamUrl, + }, + }); + + if (existingTeamWithUrl) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + if (!customerId) { + throw new AppError(AppErrorCode.UNKNOWN_ERROR, 'Missing customer ID for pending teams.'); + } + + return await tx.teamPending.create({ + data: { + name: teamName, + url: teamUrl, + ownerUserId: user.id, + customerId, + }, + }); + }); + + return { + paymentRequired: true, + pendingTeamId: pendingTeam.id, + }; + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('url')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + throw err; + } +}; + +export type CreateTeamFromPendingTeamOptions = { + pendingTeamId: number; + subscription: Stripe.Subscription; +}; + +export const createTeamFromPendingTeam = async ({ + pendingTeamId, + subscription, +}: CreateTeamFromPendingTeamOptions) => { + return await prisma.$transaction(async (tx) => { + const pendingTeam = await tx.teamPending.findUniqueOrThrow({ + where: { + id: pendingTeamId, + }, + }); + + await tx.teamPending.delete({ + where: { + id: pendingTeamId, + }, + }); + + const team = await tx.team.create({ + data: { + name: pendingTeam.name, + url: pendingTeam.url, + ownerUserId: pendingTeam.ownerUserId, + customerId: pendingTeam.customerId, + members: { + create: [ + { + userId: pendingTeam.ownerUserId, + role: TeamMemberRole.ADMIN, + }, + ], + }, + }, + }); + + await tx.subscription.upsert( + mapStripeSubscriptionToPrismaUpsertAction(subscription, undefined, team.id), + ); + + // Attach the team ID to the subscription metadata for sanity reasons. + await stripe.subscriptions + .update(subscription.id, { + metadata: { + teamId: team.id.toString(), + }, + }) + .catch((e) => { + console.error(e); + // Non-critical error, but we want to log it so we can rectify it. + // Todo: Teams - Alert us. + }); + + return team; + }); +}; diff --git a/packages/lib/server-only/team/delete-team-email-verification.ts b/packages/lib/server-only/team/delete-team-email-verification.ts new file mode 100644 index 000000000..fee39553f --- /dev/null +++ b/packages/lib/server-only/team/delete-team-email-verification.ts @@ -0,0 +1,34 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamEmailVerificationOptions = { + userId: number; + teamId: number; +}; + +export const deleteTeamEmailVerification = async ({ + userId, + teamId, +}: DeleteTeamEmailVerificationOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + await tx.teamEmailVerification.delete({ + where: { + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team-email.ts b/packages/lib/server-only/team/delete-team-email.ts new file mode 100644 index 000000000..c5139a971 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-email.ts @@ -0,0 +1,93 @@ +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { TeamEmailRemovedTemplate } from '@documenso/email/templates/team-email-removed'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamEmailOptions = { + userId: number; + userEmail: string; + teamId: number; +}; + +/** + * Delete a team email. + * + * The user must either be part of the team with the required permissions, or the owner of the email. + */ +export const deleteTeamEmail = async ({ userId, userEmail, teamId }: DeleteTeamEmailOptions) => { + const team = await prisma.$transaction(async (tx) => { + const foundTeam = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + OR: [ + { + teamEmail: { + email: userEmail, + }, + }, + { + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + ], + }, + include: { + teamEmail: true, + owner: { + select: { + name: true, + email: true, + }, + }, + }, + }); + + await tx.teamEmail.delete({ + where: { + teamId, + }, + }); + + return foundTeam; + }); + + try { + const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; + + const template = createElement(TeamEmailRemovedTemplate, { + assetBaseUrl, + baseUrl: WEBAPP_BASE_URL, + teamEmail: team.teamEmail?.email ?? '', + teamName: team.name, + teamUrl: team.url, + }); + + await mailer.sendMail({ + to: { + address: team.owner.email, + name: team.owner.name ?? '', + }, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `Team email has been revoked for ${team.name}`, + html: render(template), + text: render(template, { plainText: true }), + }); + } catch (e) { + // Todo: Teams - Alert us. + // We don't want to prevent a user from revoking access because an email could not be sent. + } +}; diff --git a/packages/lib/server-only/team/delete-team-invitations.ts b/packages/lib/server-only/team/delete-team-invitations.ts new file mode 100644 index 000000000..a2baf8352 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-invitations.ts @@ -0,0 +1,47 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type DeleteTeamMemberInvitationsOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The ID of the team to remove members from. + */ + teamId: number; + + /** + * The IDs of the invitations to remove. + */ + invitationIds: number[]; +}; + +export const deleteTeamMemberInvitations = async ({ + userId, + teamId, + invitationIds, +}: DeleteTeamMemberInvitationsOptions) => { + await prisma.$transaction(async (tx) => { + await tx.teamMember.findFirstOrThrow({ + where: { + userId, + teamId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }); + + await tx.teamMemberInvite.deleteMany({ + where: { + id: { + in: invitationIds, + }, + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team-members.ts b/packages/lib/server-only/team/delete-team-members.ts new file mode 100644 index 000000000..7e282af5a --- /dev/null +++ b/packages/lib/server-only/team/delete-team-members.ts @@ -0,0 +1,102 @@ +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamMembersOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The ID of the team to remove members from. + */ + teamId: number; + + /** + * The IDs of the team members to remove. + */ + teamMemberIds: number[]; +}; + +export const deleteTeamMembers = async ({ + userId, + teamId, + teamMemberIds, +}: DeleteTeamMembersOptions) => { + await prisma.$transaction(async (tx) => { + // Find the team and validate that the user is allowed to remove members. + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + members: { + select: { + id: true, + userId: true, + role: true, + }, + }, + subscription: true, + }, + }); + + const currentTeamMember = team.members.find((member) => member.userId === userId); + const teamMembersToRemove = team.members.filter((member) => teamMemberIds.includes(member.id)); + + if (!currentTeamMember) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Team member record does not exist'); + } + + if (teamMembersToRemove.find((member) => member.userId === team.ownerUserId)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the team owner'); + } + + const isMemberToRemoveHigherRole = teamMembersToRemove.some( + (member) => !isTeamRoleWithinUserHierarchy(currentTeamMember.role, member.role), + ); + + if (isMemberToRemoveHigherRole) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role'); + } + + // Remove the team members. + await tx.teamMember.deleteMany({ + where: { + id: { + in: teamMemberIds, + }, + teamId, + userId: { + not: team.ownerUserId, + }, + }, + }); + + if (IS_BILLING_ENABLED && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }); +}; diff --git a/packages/lib/server-only/team/delete-team-pending.ts b/packages/lib/server-only/team/delete-team-pending.ts new file mode 100644 index 000000000..b339fd862 --- /dev/null +++ b/packages/lib/server-only/team/delete-team-pending.ts @@ -0,0 +1,15 @@ +import { prisma } from '@documenso/prisma'; + +export type DeleteTeamPendingOptions = { + userId: number; + pendingTeamId: number; +}; + +export const deleteTeamPending = async ({ userId, pendingTeamId }: DeleteTeamPendingOptions) => { + await prisma.teamPending.delete({ + where: { + id: pendingTeamId, + ownerUserId: userId, + }, + }); +}; diff --git a/packages/lib/server-only/team/delete-team-transfer-request.ts b/packages/lib/server-only/team/delete-team-transfer-request.ts new file mode 100644 index 000000000..245a72b5a --- /dev/null +++ b/packages/lib/server-only/team/delete-team-transfer-request.ts @@ -0,0 +1,42 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type DeleteTeamTransferRequestOptions = { + /** + * The ID of the user deleting the transfer. + */ + userId: number; + + /** + * The ID of the team whose team transfer request should be deleted. + */ + teamId: number; +}; + +export const deleteTeamTransferRequest = async ({ + userId, + teamId, +}: DeleteTeamTransferRequestOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_TEAM_TRANSFER_REQUEST'], + }, + }, + }, + }, + }); + + await tx.teamTransferVerification.delete({ + where: { + teamId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts new file mode 100644 index 000000000..dffc044d8 --- /dev/null +++ b/packages/lib/server-only/team/delete-team.ts @@ -0,0 +1,42 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError } from '../../errors/app-error'; +import { stripe } from '../stripe'; + +export type DeleteTeamOptions = { + userId: number; + teamId: number; +}; + +export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: userId, + }, + include: { + subscription: true, + }, + }); + + if (team.subscription) { + await stripe.subscriptions + .cancel(team.subscription.planId, { + prorate: false, + invoice_now: true, + }) + .catch((err) => { + console.error(err); + throw AppError.parseError(err); + }); + } + + await tx.team.delete({ + where: { + id: teamId, + ownerUserId: userId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/find-team-invoices.ts b/packages/lib/server-only/team/find-team-invoices.ts new file mode 100644 index 000000000..bbc84f3fd --- /dev/null +++ b/packages/lib/server-only/team/find-team-invoices.ts @@ -0,0 +1,52 @@ +import { getInvoices } from '@documenso/ee/server-only/stripe/get-invoices'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +export interface FindTeamInvoicesOptions { + userId: number; + teamId: number; +} + +export const findTeamInvoices = async ({ userId, teamId }: FindTeamInvoicesOptions) => { + const team = await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + if (!team.customerId) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Team has no customer ID.'); + } + + const results = await getInvoices({ customerId: team.customerId }); + + if (!results) { + return null; + } + + return { + ...results, + data: results.data.map((invoice) => ({ + invoicePdf: invoice.invoice_pdf, + hostedInvoicePdf: invoice.hosted_invoice_url, + status: invoice.status, + subtotal: invoice.subtotal, + total: invoice.total, + amountPaid: invoice.amount_paid, + amountDue: invoice.amount_due, + created: invoice.created, + paid: invoice.paid, + quantity: invoice.lines.data[0].quantity ?? 0, + currency: invoice.currency, + })), + }; +}; diff --git a/packages/lib/server-only/team/find-team-member-invites.ts b/packages/lib/server-only/team/find-team-member-invites.ts new file mode 100644 index 000000000..8100008b8 --- /dev/null +++ b/packages/lib/server-only/team/find-team-member-invites.ts @@ -0,0 +1,91 @@ +import { P, match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { TeamMemberInvite } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; +import type { FindResultSet } from '../../types/find-result-set'; + +export interface FindTeamMemberInvitesOptions { + userId: number; + teamId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof TeamMemberInvite; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamMemberInvites = async ({ + userId, + teamId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamMemberInvitesOptions) => { + const orderByColumn = orderBy?.column ?? 'email'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + // Check that the user belongs to the team they are trying to find invites in. + const userTeam = await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + const termFilters: Prisma.TeamMemberInviteWhereInput | undefined = match(term) + .with(P.string.minLength(1), () => ({ + email: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + })) + .otherwise(() => undefined); + + const whereClause: Prisma.TeamMemberInviteWhereInput = { + ...termFilters, + teamId: userTeam.id, + }; + + const [data, count] = await Promise.all([ + prisma.teamMemberInvite.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + // Exclude token attribute. + select: { + id: true, + teamId: true, + email: true, + role: true, + createdAt: true, + }, + }), + prisma.teamMemberInvite.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/find-team-members.ts b/packages/lib/server-only/team/find-team-members.ts new file mode 100644 index 000000000..4a1ab8511 --- /dev/null +++ b/packages/lib/server-only/team/find-team-members.ts @@ -0,0 +1,100 @@ +import { P, match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { TeamMember } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +import type { FindResultSet } from '../../types/find-result-set'; + +export interface FindTeamMembersOptions { + userId: number; + teamId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof TeamMember | 'name'; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamMembers = async ({ + userId, + teamId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamMembersOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + // Check that the user belongs to the team they are trying to find members in. + const userTeam = await prisma.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + }, + }, + }, + }); + + const termFilters: Prisma.TeamMemberWhereInput | undefined = match(term) + .with(P.string.minLength(1), () => ({ + user: { + name: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + }, + })) + .otherwise(() => undefined); + + const whereClause: Prisma.TeamMemberWhereInput = { + ...termFilters, + teamId: userTeam.id, + }; + + let orderByClause: Prisma.TeamMemberOrderByWithRelationInput = { + [orderByColumn]: orderByDirection, + }; + + // Name field is nested in the user so we have to handle it differently. + if (orderByColumn === 'name') { + orderByClause = { + user: { + name: orderByDirection, + }, + }; + } + + const [data, count] = await Promise.all([ + prisma.teamMember.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: orderByClause, + include: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + }), + prisma.teamMember.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/find-teams-pending.ts b/packages/lib/server-only/team/find-teams-pending.ts new file mode 100644 index 000000000..d079c6f5f --- /dev/null +++ b/packages/lib/server-only/team/find-teams-pending.ts @@ -0,0 +1,58 @@ +import { prisma } from '@documenso/prisma'; +import type { Team } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindTeamsPendingOptions { + userId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Team; + direction: 'asc' | 'desc'; + }; +} + +export const findTeamsPending = async ({ + userId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamsPendingOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause: Prisma.TeamPendingWhereInput = { + ownerUserId: userId, + }; + + if (term && term.length > 0) { + whereClause.name = { + contains: term, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.teamPending.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + }), + prisma.teamPending.count({ + where: whereClause, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/lib/server-only/team/find-teams.ts b/packages/lib/server-only/team/find-teams.ts new file mode 100644 index 000000000..f5376a65d --- /dev/null +++ b/packages/lib/server-only/team/find-teams.ts @@ -0,0 +1,76 @@ +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { prisma } from '@documenso/prisma'; +import type { Team } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindTeamsOptions { + userId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Team; + direction: 'asc' | 'desc'; + }; +} + +export const findTeams = async ({ + userId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindTeamsOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause: Prisma.TeamWhereInput = { + members: { + some: { + userId, + }, + }, + }; + + if (term && term.length > 0) { + whereClause.name = { + contains: term, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.team.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + members: { + where: { + userId, + }, + }, + }, + }), + prisma.team.count({ + where: whereClause, + }), + ]); + + const maskedData = data.map((team) => ({ + ...team, + currentTeamMember: team.members[0], + members: undefined, + })); + + return { + data: maskedData, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + } satisfies FindResultSet; +}; diff --git a/packages/lib/server-only/team/get-team-email-by-email.ts b/packages/lib/server-only/team/get-team-email-by-email.ts new file mode 100644 index 000000000..665694db4 --- /dev/null +++ b/packages/lib/server-only/team/get-team-email-by-email.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamEmailByEmailOptions = { + email: string; +}; + +export const getTeamEmailByEmail = async ({ email }: GetTeamEmailByEmailOptions) => { + return await prisma.teamEmail.findFirst({ + where: { + email, + }, + include: { + team: { + select: { + id: true, + name: true, + url: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/get-team-invitations.ts b/packages/lib/server-only/team/get-team-invitations.ts new file mode 100644 index 000000000..737f1b3f7 --- /dev/null +++ b/packages/lib/server-only/team/get-team-invitations.ts @@ -0,0 +1,22 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamInvitationsOptions = { + email: string; +}; + +export const getTeamInvitations = async ({ email }: GetTeamInvitationsOptions) => { + return await prisma.teamMemberInvite.findMany({ + where: { + email, + }, + include: { + team: { + select: { + id: true, + name: true, + url: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/get-team-members.ts b/packages/lib/server-only/team/get-team-members.ts new file mode 100644 index 000000000..a29ed6e1d --- /dev/null +++ b/packages/lib/server-only/team/get-team-members.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamMembersOptions = { + userId: number; + teamId: number; +}; + +/** + * Get all team members for a given team. + */ +export const getTeamMembers = async ({ userId, teamId }: GetTeamMembersOptions) => { + return await prisma.teamMember.findMany({ + where: { + team: { + id: teamId, + members: { + some: { + userId: userId, + }, + }, + }, + }, + include: { + user: { + select: { + id: true, + email: true, + name: true, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/team/get-team.ts b/packages/lib/server-only/team/get-team.ts new file mode 100644 index 000000000..59331202e --- /dev/null +++ b/packages/lib/server-only/team/get-team.ts @@ -0,0 +1,95 @@ +import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; + +export type GetTeamByIdOptions = { + userId?: number; + teamId: number; +}; + +/** + * Get a team given a teamId. + * + * Provide an optional userId to check that the user is a member of the team. + */ +export const getTeamById = async ({ userId, teamId }: GetTeamByIdOptions) => { + const whereFilter: Prisma.TeamWhereUniqueInput = { + id: teamId, + }; + + if (userId !== undefined) { + whereFilter['members'] = { + some: { + userId, + }, + }; + } + + const result = await prisma.team.findUniqueOrThrow({ + where: whereFilter, + include: { + teamEmail: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + const { members, ...team } = result; + + return { + ...team, + currentTeamMember: userId !== undefined ? members[0] : null, + }; +}; + +export type GetTeamByUrlOptions = { + userId: number; + teamUrl: string; +}; + +/** + * Get a team given a team URL. + */ +export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) => { + const whereFilter: Prisma.TeamWhereUniqueInput = { + url: teamUrl, + }; + + if (userId !== undefined) { + whereFilter['members'] = { + some: { + userId, + }, + }; + } + + const result = await prisma.team.findUniqueOrThrow({ + where: whereFilter, + include: { + teamEmail: true, + emailVerification: true, + transferVerification: true, + subscription: true, + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + const { members, ...team } = result; + + return { + ...team, + currentTeamMember: members[0], + }; +}; diff --git a/packages/lib/server-only/team/get-teams.ts b/packages/lib/server-only/team/get-teams.ts new file mode 100644 index 000000000..57a9fb83e --- /dev/null +++ b/packages/lib/server-only/team/get-teams.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; + +export type GetTeamsOptions = { + userId: number; +}; +export type GetTeamsResponse = Awaited>; + +export const getTeams = async ({ userId }: GetTeamsOptions) => { + const teams = await prisma.team.findMany({ + where: { + members: { + some: { + userId, + }, + }, + }, + include: { + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + return teams.map(({ members, ...team }) => ({ + ...team, + currentTeamMember: members[0], + })); +}; diff --git a/packages/lib/server-only/team/leave-team.ts b/packages/lib/server-only/team/leave-team.ts new file mode 100644 index 000000000..d0c6fe145 --- /dev/null +++ b/packages/lib/server-only/team/leave-team.ts @@ -0,0 +1,59 @@ +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; + +export type LeaveTeamOptions = { + /** + * The ID of the user who is leaving the team. + */ + userId: number; + + /** + * The ID of the team the user is leaving. + */ + teamId: number; +}; + +export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: { + not: userId, + }, + }, + include: { + subscription: true, + }, + }); + + await tx.teamMember.delete({ + where: { + userId_teamId: { + userId, + teamId, + }, + team: { + ownerUserId: { + not: userId, + }, + }, + }, + }); + + if (IS_BILLING_ENABLED && team.subscription) { + const numberOfSeats = await tx.teamMember.count({ + where: { + teamId, + }, + }); + + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: numberOfSeats, + }); + } + }); +}; diff --git a/packages/lib/server-only/team/request-team-ownership-transfer.ts b/packages/lib/server-only/team/request-team-ownership-transfer.ts new file mode 100644 index 000000000..7da976ee1 --- /dev/null +++ b/packages/lib/server-only/team/request-team-ownership-transfer.ts @@ -0,0 +1,106 @@ +import { createElement } from 'react'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import { TeamTransferRequestTemplate } from '@documenso/email/templates/team-transfer-request'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { createTokenVerification } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; + +export type RequestTeamOwnershipTransferOptions = { + /** + * The ID of the user initiating the transfer. + */ + userId: number; + + /** + * The name of the user initiating the transfer. + */ + userName: string; + + /** + * The ID of the team whose ownership is being transferred. + */ + teamId: number; + + /** + * The user ID of the new owner. + */ + newOwnerUserId: number; + + /** + * Whether to clear any current payment methods attached to the team. + */ + clearPaymentMethods: boolean; +}; + +export const requestTeamOwnershipTransfer = async ({ + userId, + userName, + teamId, + newOwnerUserId, +}: RequestTeamOwnershipTransferOptions) => { + // Todo: Clear payment methods disabled for now. + const clearPaymentMethods = false; + + await prisma.$transaction(async (tx) => { + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + ownerUserId: userId, + members: { + some: { + userId: newOwnerUserId, + }, + }, + }, + }); + + const newOwnerUser = await tx.user.findFirstOrThrow({ + where: { + id: newOwnerUserId, + }, + }); + + const { token, expiresAt } = createTokenVerification({ minute: 10 }); + + const teamVerificationPayload = { + teamId, + token, + expiresAt, + userId: newOwnerUserId, + name: newOwnerUser.name ?? '', + email: newOwnerUser.email, + clearPaymentMethods, + }; + + await tx.teamTransferVerification.upsert({ + where: { + teamId, + }, + create: teamVerificationPayload, + update: teamVerificationPayload, + }); + + const template = createElement(TeamTransferRequestTemplate, { + assetBaseUrl: WEBAPP_BASE_URL, + baseUrl: WEBAPP_BASE_URL, + senderName: userName, + teamName: team.name, + teamUrl: team.url, + token, + }); + + await mailer.sendMail({ + to: newOwnerUser.email, + from: { + name: FROM_NAME, + address: FROM_ADDRESS, + }, + subject: `You have been requested to take ownership of team ${team.name} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); + }); +}; diff --git a/packages/lib/server-only/team/resend-team-email-verification.ts b/packages/lib/server-only/team/resend-team-email-verification.ts new file mode 100644 index 000000000..55afe61ce --- /dev/null +++ b/packages/lib/server-only/team/resend-team-email-verification.ts @@ -0,0 +1,65 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { createTokenVerification } from '@documenso/lib/utils/token-verification'; +import { prisma } from '@documenso/prisma'; + +import { sendTeamEmailVerificationEmail } from './create-team-email-verification'; + +export type ResendTeamMemberInvitationOptions = { + userId: number; + teamId: number; +}; + +/** + * Resend a team email verification with a new token. + */ +export const resendTeamEmailVerification = async ({ + userId, + teamId, +}: ResendTeamMemberInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + emailVerification: true, + }, + }); + + if (!team) { + throw new AppError('TeamNotFound', 'User is not a member of the team.'); + } + + const { emailVerification } = team; + + if (!emailVerification) { + throw new AppError( + 'VerificationNotFound', + 'No team email verification exists for this team.', + ); + } + + const { token, expiresAt } = createTokenVerification({ hours: 1 }); + + await tx.teamEmailVerification.update({ + where: { + teamId, + }, + data: { + token, + expiresAt, + }, + }); + + await sendTeamEmailVerificationEmail(emailVerification.email, token, team.name, team.url); + }); +}; diff --git a/packages/lib/server-only/team/resend-team-member-invitation.ts b/packages/lib/server-only/team/resend-team-member-invitation.ts new file mode 100644 index 000000000..fb860ccc0 --- /dev/null +++ b/packages/lib/server-only/team/resend-team-member-invitation.ts @@ -0,0 +1,76 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { sendTeamMemberInviteEmail } from './create-team-member-invites'; + +export type ResendTeamMemberInvitationOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The name of the user who is initiating this action. + */ + userName: string; + + /** + * The ID of the team. + */ + teamId: number; + + /** + * The IDs of the invitations to resend. + */ + invitationId: number; +}; + +/** + * Resend an email for a given team member invite. + */ +export const resendTeamMemberInvitation = async ({ + userId, + userName, + teamId, + invitationId, +}: ResendTeamMemberInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const team = await tx.team.findUniqueOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + }); + + if (!team) { + throw new AppError('TeamNotFound', 'User is not a valid member of the team.'); + } + + const teamMemberInvite = await tx.teamMemberInvite.findUniqueOrThrow({ + where: { + id: invitationId, + teamId, + }, + }); + + if (!teamMemberInvite) { + throw new AppError('InviteNotFound', 'No invite exists for this user.'); + } + + await sendTeamMemberInviteEmail({ + email: teamMemberInvite.email, + token: teamMemberInvite.token, + teamName: team.name, + teamUrl: team.url, + senderName: userName, + }); + }); +}; diff --git a/packages/lib/server-only/team/transfer-team-ownership.ts b/packages/lib/server-only/team/transfer-team-ownership.ts new file mode 100644 index 000000000..bb14eec55 --- /dev/null +++ b/packages/lib/server-only/team/transfer-team-ownership.ts @@ -0,0 +1,88 @@ +import type Stripe from 'stripe'; + +import { transferTeamSubscription } from '@documenso/ee/server-only/stripe/transfer-team-subscription'; +import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated'; +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; + +export type TransferTeamOwnershipOptions = { + token: string; +}; + +export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOptions) => { + await prisma.$transaction(async (tx) => { + const teamTransferVerification = await tx.teamTransferVerification.findFirstOrThrow({ + where: { + token, + }, + include: { + team: { + include: { + subscription: true, + }, + }, + }, + }); + + const { team, userId: newOwnerUserId } = teamTransferVerification; + + await tx.teamTransferVerification.delete({ + where: { + teamId: team.id, + }, + }); + + const newOwnerUser = await tx.user.findFirstOrThrow({ + where: { + id: newOwnerUserId, + teamMembers: { + some: { + teamId: team.id, + }, + }, + }, + include: { + Subscription: true, + }, + }); + + let teamSubscription: Stripe.Subscription | null = null; + + if (IS_BILLING_ENABLED) { + teamSubscription = await transferTeamSubscription({ + user: newOwnerUser, + team, + clearPaymentMethods: teamTransferVerification.clearPaymentMethods, + }); + } + + if (teamSubscription) { + await tx.subscription.upsert( + mapStripeSubscriptionToPrismaUpsertAction(teamSubscription, undefined, team.id), + ); + } + + await tx.team.update({ + where: { + id: team.id, + }, + data: { + ownerUserId: newOwnerUserId, + members: { + update: { + where: { + userId_teamId: { + teamId: team.id, + userId: newOwnerUserId, + }, + }, + data: { + role: TeamMemberRole.ADMIN, + }, + }, + }, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team-email.ts b/packages/lib/server-only/team/update-team-email.ts new file mode 100644 index 000000000..05023efc7 --- /dev/null +++ b/packages/lib/server-only/team/update-team-email.ts @@ -0,0 +1,42 @@ +import { prisma } from '@documenso/prisma'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams'; + +export type UpdateTeamEmailOptions = { + userId: number; + teamId: number; + data: { + name: string; + }; +}; + +export const updateTeamEmail = async ({ userId, teamId, data }: UpdateTeamEmailOptions) => { + await prisma.$transaction(async (tx) => { + await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + teamEmail: { + isNot: null, + }, + }, + }); + + await tx.teamEmail.update({ + where: { + teamId, + }, + data: { + // Note: Never allow the email to be updated without re-verifying via email. + name: data.name, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team-member.ts b/packages/lib/server-only/team/update-team-member.ts new file mode 100644 index 000000000..9a4a85f85 --- /dev/null +++ b/packages/lib/server-only/team/update-team-member.ts @@ -0,0 +1,92 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams'; +import { prisma } from '@documenso/prisma'; +import type { TeamMemberRole } from '@documenso/prisma/client'; + +export type UpdateTeamMemberOptions = { + userId: number; + teamId: number; + teamMemberId: number; + data: { + role: TeamMemberRole; + }; +}; + +export const updateTeamMember = async ({ + userId, + teamId, + teamMemberId, + data, +}: UpdateTeamMemberOptions) => { + await prisma.$transaction(async (tx) => { + // Find the team and validate that the user is allowed to update members. + const team = await tx.team.findFirstOrThrow({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + include: { + members: { + select: { + id: true, + userId: true, + role: true, + }, + }, + }, + }); + + const currentTeamMember = team.members.find((member) => member.userId === userId); + const teamMemberToUpdate = team.members.find((member) => member.id === teamMemberId); + + if (!teamMemberToUpdate || !currentTeamMember) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Team member does not exist'); + } + + if (teamMemberToUpdate.userId === team.ownerUserId) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update the owner'); + } + + const isMemberToUpdateHigherRole = !isTeamRoleWithinUserHierarchy( + currentTeamMember.role, + teamMemberToUpdate.role, + ); + + if (isMemberToUpdateHigherRole) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update a member with a higher role'); + } + + const isNewMemberRoleHigherThanCurrentRole = !isTeamRoleWithinUserHierarchy( + currentTeamMember.role, + data.role, + ); + + if (isNewMemberRoleHigherThanCurrentRole) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'Cannot give a member a role higher than the user initating the update', + ); + } + + return await tx.teamMember.update({ + where: { + id: teamMemberId, + teamId, + userId: { + not: team.ownerUserId, + }, + }, + data: { + role: data.role, + }, + }); + }); +}; diff --git a/packages/lib/server-only/team/update-team.ts b/packages/lib/server-only/team/update-team.ts new file mode 100644 index 000000000..b172d3359 --- /dev/null +++ b/packages/lib/server-only/team/update-team.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +export type UpdateTeamOptions = { + userId: number; + teamId: number; + data: { + name?: string; + url?: string; + }; +}; + +export const updateTeam = async ({ userId, teamId, data }: UpdateTeamOptions) => { + try { + await prisma.$transaction(async (tx) => { + const foundPendingTeamWithUrl = await tx.teamPending.findFirst({ + where: { + url: data.url, + }, + }); + + if (foundPendingTeamWithUrl) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + const team = await tx.team.update({ + where: { + id: teamId, + members: { + some: { + userId, + role: { + in: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }, + }, + }, + }, + data: { + url: data.url, + name: data.name, + }, + }); + + return team; + }); + } catch (err) { + console.error(err); + + if (!(err instanceof Prisma.PrismaClientKnownRequestError)) { + throw err; + } + + const target = z.array(z.string()).safeParse(err.meta?.target); + + if (err.code === 'P2002' && target.success && target.data.includes('url')) { + throw new AppError(AppErrorCode.ALREADY_EXISTS, 'Team URL already exists.'); + } + + throw err; + } +}; diff --git a/packages/lib/server-only/user/create-user.ts b/packages/lib/server-only/user/create-user.ts index f7db60c85..42a9f128c 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -1,11 +1,12 @@ import { hash } from 'bcrypt'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; +import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; import { prisma } from '@documenso/prisma'; -import { IdentityProvider } from '@documenso/prisma/client'; +import { IdentityProvider, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { IS_BILLING_ENABLED } from '../../constants/app'; import { SALT_ROUNDS } from '../../constants/auth'; -import { getFlag } from '../../universal/get-feature-flag'; export interface CreateUserOptions { name: string; @@ -15,8 +16,6 @@ export interface CreateUserOptions { } export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => { - const isBillingEnabled = await getFlag('app_billing'); - const hashedPassword = await hash(password, SALT_ROUNDS); const userExists = await prisma.user.findFirst({ @@ -29,7 +28,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse throw new Error('User already exists'); } - let user = await prisma.user.create({ + const user = await prisma.user.create({ data: { name, email: email.toLowerCase(), @@ -39,12 +38,81 @@ export const createUser = async ({ name, email, password, signature }: CreateUse }, }); - if (isBillingEnabled) { + const acceptedTeamInvites = await prisma.teamMemberInvite.findMany({ + where: { + email: { + equals: email, + mode: Prisma.QueryMode.insensitive, + }, + status: TeamMemberInviteStatus.ACCEPTED, + }, + }); + + // For each team invite, add the user to the team and delete the team invite. + // If an error occurs, reset the invitation to not accepted. + await Promise.allSettled( + acceptedTeamInvites.map(async (invite) => + prisma + .$transaction(async (tx) => { + await tx.teamMember.create({ + data: { + teamId: invite.teamId, + userId: user.id, + role: invite.role, + }, + }); + + await tx.teamMemberInvite.delete({ + where: { + id: invite.id, + }, + }); + + if (!IS_BILLING_ENABLED) { + return; + } + + const team = await tx.team.findFirstOrThrow({ + where: { + id: invite.teamId, + }, + include: { + members: { + select: { + id: true, + }, + }, + subscription: true, + }, + }); + + if (team.subscription) { + await updateSubscriptionItemQuantity({ + priceId: team.subscription.priceId, + subscriptionId: team.subscription.planId, + quantity: team.members.length, + }); + } + }) + .catch(async () => { + await prisma.teamMemberInvite.update({ + where: { + id: invite.id, + }, + data: { + status: TeamMemberInviteStatus.PENDING, + }, + }); + }), + ), + ); + + // Update the user record with a new or existing Stripe customer record. + if (IS_BILLING_ENABLED) { try { - const stripeSession = await getStripeCustomerByUser(user); - user = stripeSession.user; - } catch (e) { - console.error(e); + return await getStripeCustomerByUser(user).then((session) => session.user); + } catch (err) { + console.error(err); } } diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts new file mode 100644 index 000000000..ca85addbb --- /dev/null +++ b/packages/lib/utils/billing.ts @@ -0,0 +1,16 @@ +import type { Subscription } from '.prisma/client'; +import { SubscriptionStatus } from '.prisma/client'; + +/** + * Returns true if there is a subscription that is active and is a community plan. + */ +export const subscriptionsContainsActiveCommunityPlan = ( + subscriptions: Subscription[], + communityPlanPriceIds: string[], +) => { + return subscriptions.some( + (subscription) => + subscription.status === SubscriptionStatus.ACTIVE && + communityPlanPriceIds.includes(subscription.priceId), + ); +}; diff --git a/packages/lib/utils/params.ts b/packages/lib/utils/params.ts new file mode 100644 index 000000000..a8d799400 --- /dev/null +++ b/packages/lib/utils/params.ts @@ -0,0 +1,30 @@ +/** + * From an unknown string, parse it into an integer array. + * + * Filter out unknown values. + */ +export const parseToIntegerArray = (value: unknown): number[] => { + if (typeof value !== 'string') { + return []; + } + + return value + .split(',') + .map((value) => parseInt(value, 10)) + .filter((value) => !isNaN(value)); +}; + +type GetRootHrefOptions = { + returnEmptyRootString?: boolean; +}; + +export const getRootHref = ( + params: Record | null, + options: GetRootHrefOptions = {}, +) => { + if (typeof params?.teamUrl === 'string') { + return `/t/${params.teamUrl}`; + } + + return options.returnEmptyRootString ? '' : '/'; +}; diff --git a/packages/lib/utils/recipient-formatter.ts b/packages/lib/utils/recipient-formatter.ts index 2e2bace3b..5fad45399 100644 --- a/packages/lib/utils/recipient-formatter.ts +++ b/packages/lib/utils/recipient-formatter.ts @@ -1,6 +1,6 @@ import type { Recipient } from '@documenso/prisma/client'; -export const recipientInitials = (text: string) => +export const extractInitials = (text: string) => text .split(' ') .map((name: string) => name.slice(0, 1).toUpperCase()) @@ -8,5 +8,5 @@ export const recipientInitials = (text: string) => .join(''); export const recipientAbbreviation = (recipient: Recipient) => { - return recipientInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase(); + return extractInitials(recipient.name) || recipient.email.slice(0, 1).toUpperCase(); }; diff --git a/packages/lib/utils/teams.ts b/packages/lib/utils/teams.ts new file mode 100644 index 000000000..eb9be2c2b --- /dev/null +++ b/packages/lib/utils/teams.ts @@ -0,0 +1,42 @@ +import { WEBAPP_BASE_URL } from '../constants/app'; +import type { TEAM_MEMBER_ROLE_MAP } from '../constants/teams'; +import { TEAM_MEMBER_ROLE_HIERARCHY, TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../constants/teams'; + +export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => { + const formattedBaseUrl = (baseUrl ?? WEBAPP_BASE_URL).replace(/https?:\/\//, ''); + + return `${formattedBaseUrl}/t/${teamUrl}`; +}; + +export const formatDocumentsPath = (teamUrl?: string) => { + return teamUrl ? `/t/${teamUrl}/documents` : '/documents'; +}; + +/** + * Determines whether a team member can execute a given action. + * + * @param action The action the user is trying to execute. + * @param role The current role of the user. + * @returns Whether the user can execute the action. + */ +export const canExecuteTeamAction = ( + action: keyof typeof TEAM_MEMBER_ROLE_PERMISSIONS_MAP, + role: keyof typeof TEAM_MEMBER_ROLE_MAP, +) => { + return TEAM_MEMBER_ROLE_PERMISSIONS_MAP[action].some((i) => i === role); +}; + +/** + * Compares the provided `currentUserRole` with the provided `roleToCheck` to determine + * whether the `currentUserRole` has permission to modify the `roleToCheck`. + * + * @param currentUserRole Role of the current user + * @param roleToCheck Role of another user to see if the current user can modify + * @returns True if the current user can modify the other user, false otherwise + */ +export const isTeamRoleWithinUserHierarchy = ( + currentUserRole: keyof typeof TEAM_MEMBER_ROLE_MAP, + roleToCheck: keyof typeof TEAM_MEMBER_ROLE_MAP, +) => { + return TEAM_MEMBER_ROLE_HIERARCHY[currentUserRole].some((i) => i === roleToCheck); +}; diff --git a/packages/lib/utils/token-verification.ts b/packages/lib/utils/token-verification.ts new file mode 100644 index 000000000..c57ddd1e5 --- /dev/null +++ b/packages/lib/utils/token-verification.ts @@ -0,0 +1,21 @@ +import type { DurationLike } from 'luxon'; +import { DateTime } from 'luxon'; +import { nanoid } from 'nanoid'; + +/** + * Create a token verification object. + * + * @param expiry The date the token expires, or the duration until the token expires. + */ +export const createTokenVerification = (expiry: Date | DurationLike) => { + const expiresAt = expiry instanceof Date ? expiry : DateTime.now().plus(expiry).toJSDate(); + + return { + expiresAt, + token: nanoid(32), + }; +}; + +export const isTokenExpired = (expiresAt: Date) => { + return expiresAt < new Date(); +}; diff --git a/packages/prisma/migrations/20240205040421_add_teams/migration.sql b/packages/prisma/migrations/20240205040421_add_teams/migration.sql new file mode 100644 index 000000000..f80799aab --- /dev/null +++ b/packages/prisma/migrations/20240205040421_add_teams/migration.sql @@ -0,0 +1,187 @@ +/* + Warnings: + + - A unique constraint covering the columns `[teamId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "TeamMemberRole" AS ENUM ('ADMIN', 'MANAGER', 'MEMBER'); + +-- CreateEnum +CREATE TYPE "TeamMemberInviteStatus" AS ENUM ('ACCEPTED', 'PENDING'); + +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "teamId" INTEGER; + +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "teamId" INTEGER, +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "Team" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "customerId" TEXT, + "ownerUserId" INTEGER NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamPending" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "customerId" TEXT NOT NULL, + "ownerUserId" INTEGER NOT NULL, + + CONSTRAINT "TeamPending_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" SERIAL NOT NULL, + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "role" "TeamMemberRole" NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamEmail" ( + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + + CONSTRAINT "TeamEmail_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamEmailVerification" ( + "teamId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamEmailVerification_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamTransferVerification" ( + "teamId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "clearPaymentMethods" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "TeamTransferVerification_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "TeamMemberInvite" ( + "id" SERIAL NOT NULL, + "teamId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "email" TEXT NOT NULL, + "status" "TeamMemberInviteStatus" NOT NULL DEFAULT 'PENDING', + "role" "TeamMemberRole" NOT NULL, + "token" TEXT NOT NULL, + + CONSTRAINT "TeamMemberInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_url_key" ON "Team"("url"); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_customerId_key" ON "Team"("customerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamPending_url_key" ON "TeamPending"("url"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamPending_customerId_key" ON "TeamPending"("customerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMember_userId_teamId_key" ON "TeamMember"("userId", "teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmail_teamId_key" ON "TeamEmail"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmail_email_key" ON "TeamEmail"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmailVerification_teamId_key" ON "TeamEmailVerification"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamEmailVerification_token_key" ON "TeamEmailVerification"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamTransferVerification_teamId_key" ON "TeamTransferVerification"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamTransferVerification_token_key" ON "TeamTransferVerification"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMemberInvite_token_key" ON "TeamMemberInvite"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamMemberInvite_teamId_email_key" ON "TeamMemberInvite"("teamId", "email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_teamId_key" ON "Subscription"("teamId"); + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Document" ADD CONSTRAINT "Document_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamPending" ADD CONSTRAINT "TeamPending_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEmail" ADD CONSTRAINT "TeamEmail_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamEmailVerification" ADD CONSTRAINT "TeamEmailVerification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamTransferVerification" ADD CONSTRAINT "TeamTransferVerification_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMemberInvite" ADD CONSTRAINT "TeamMemberInvite_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "Subscription" +ADD CONSTRAINT teamId_or_userId_check +CHECK ( + ( + "teamId" IS NOT NULL + AND "userId" IS NULL + ) + OR ( + "teamId" IS NULL + AND "userId" IS NOT NULL + ) +); diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 2fb01a6ac..301b51dba 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -21,7 +21,8 @@ "@prisma/client": "5.4.2", "dotenv": "^16.3.1", "dotenv-cli": "^7.3.0", - "prisma": "5.4.2" + "prisma": "5.4.2", + "ts-pattern": "^5.0.6" }, "devDependencies": { "ts-node": "^10.9.1", diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 87d29d6b2..79dcdf6aa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -37,6 +37,9 @@ model User { Document Document[] Subscription Subscription[] PasswordResetToken PasswordResetToken[] + ownedTeams Team[] + ownedPendingTeams TeamPending[] + teamMembers TeamMember[] twoFactorSecret String? twoFactorEnabled Boolean @default(false) twoFactorBackupCodes String? @@ -103,12 +106,14 @@ model Subscription { planId String @unique priceId String periodEnd DateTime? - userId Int + userId Int? + teamId Int? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt cancelAtPeriodEnd Boolean @default(false) - User User @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + User User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) } @@ -162,6 +167,8 @@ model Document { updatedAt DateTime @default(now()) @updatedAt completedAt DateTime? deletedAt DateTime? + teamId Int? + team Team? @relation(fields: [teamId], references: [id]) @@unique([documentDataId]) @@index([userId]) @@ -300,6 +307,104 @@ model DocumentShareLink { @@unique([documentId, email]) } +enum TeamMemberRole { + ADMIN + MANAGER + MEMBER +} + +enum TeamMemberInviteStatus { + ACCEPTED + PENDING +} + +model Team { + id Int @id @default(autoincrement()) + name String + url String @unique + createdAt DateTime @default(now()) + customerId String? @unique + ownerUserId Int + members TeamMember[] + invites TeamMemberInvite[] + teamEmail TeamEmail? + emailVerification TeamEmailVerification? + transferVerification TeamTransferVerification? + + owner User @relation(fields: [ownerUserId], references: [id]) + subscription Subscription? + + document Document[] +} + +model TeamPending { + id Int @id @default(autoincrement()) + name String + url String @unique + createdAt DateTime @default(now()) + customerId String @unique + ownerUserId Int + + owner User @relation(fields: [ownerUserId], references: [id], onDelete: Cascade) +} + +model TeamMember { + id Int @id @default(autoincrement()) + teamId Int + createdAt DateTime @default(now()) + role TeamMemberRole + userId Int + user User @relation(fields: [userId], references: [id]) + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([userId, teamId]) +} + +model TeamEmail { + teamId Int @id @unique + createdAt DateTime @default(now()) + name String + email String @unique + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamEmailVerification { + teamId Int @id @unique + name String + email String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamTransferVerification { + teamId Int @id @unique + userId Int + name String + email String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + clearPaymentMethods Boolean @default(false) + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) +} + +model TeamMemberInvite { + id Int @id @default(autoincrement()) + teamId Int + createdAt DateTime @default(now()) + email String + status TeamMemberInviteStatus @default(PENDING) + role TeamMemberRole + token String @unique + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@unique([teamId, email]) +} + enum TemplateType { PUBLIC PRIVATE diff --git a/packages/prisma/seed/documents.ts b/packages/prisma/seed/documents.ts new file mode 100644 index 000000000..1f1f5cab8 --- /dev/null +++ b/packages/prisma/seed/documents.ts @@ -0,0 +1,375 @@ +import type { User } from '@prisma/client'; +import { nanoid } from 'nanoid'; +import fs from 'node:fs'; +import path from 'node:path'; +import { match } from 'ts-pattern'; + +import { prisma } from '..'; +import { + DocumentDataType, + DocumentStatus, + FieldType, + Prisma, + ReadStatus, + SendStatus, + SigningStatus, +} from '../client'; +import { seedTeam } from './teams'; +import { seedUser } from './users'; + +const examplePdf = fs + .readFileSync(path.join(__dirname, '../../../assets/example.pdf')) + .toString('base64'); + +type DocumentToSeed = { + sender: User; + recipients: (User | string)[]; + type: DocumentStatus; + documentOptions?: Partial; +}; + +export const seedDocuments = async (documents: DocumentToSeed[]) => { + await Promise.all( + documents.map(async (document, i) => + match(document.type) + .with(DocumentStatus.DRAFT, async () => + createDraftDocument(document.sender, document.recipients, { + key: i, + createDocumentOptions: document.documentOptions, + }), + ) + .with(DocumentStatus.PENDING, async () => + createPendingDocument(document.sender, document.recipients, { + key: i, + createDocumentOptions: document.documentOptions, + }), + ) + .with(DocumentStatus.COMPLETED, async () => + createCompletedDocument(document.sender, document.recipients, { + key: i, + createDocumentOptions: document.documentOptions, + }), + ) + .exhaustive(), + ), + ); +}; + +const createDraftDocument = async ( + sender: User, + recipients: (User | string)[], + options: CreateDocumentOptions = {}, +) => { + const { key, createDocumentOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + const document = await prisma.document.create({ + data: { + title: `[TEST] Document ${key} - Draft`, + status: DocumentStatus.DRAFT, + documentDataId: documentData.id, + userId: sender.id, + ...createDocumentOptions, + }, + }); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.NOT_OPENED, + sendStatus: SendStatus.NOT_SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + create: { + page: 1, + type: FieldType.NAME, + inserted: true, + customText: name, + positionX: new Prisma.Decimal(1), + positionY: new Prisma.Decimal(1), + width: new Prisma.Decimal(1), + height: new Prisma.Decimal(1), + documentId: document.id, + }, + }, + }, + }); + } +}; + +type CreateDocumentOptions = { + key?: string | number; + createDocumentOptions?: Partial; +}; + +const createPendingDocument = async ( + sender: User, + recipients: (User | string)[], + options: CreateDocumentOptions = {}, +) => { + const { key, createDocumentOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + const document = await prisma.document.create({ + data: { + title: `[TEST] Document ${key} - Pending`, + status: DocumentStatus.PENDING, + documentDataId: documentData.id, + userId: sender.id, + ...createDocumentOptions, + }, + }); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.NOT_SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + create: { + page: 1, + type: FieldType.NAME, + inserted: true, + customText: name, + positionX: new Prisma.Decimal(1), + positionY: new Prisma.Decimal(1), + width: new Prisma.Decimal(1), + height: new Prisma.Decimal(1), + documentId: document.id, + }, + }, + }, + }); + } +}; + +const createCompletedDocument = async ( + sender: User, + recipients: (User | string)[], + options: CreateDocumentOptions = {}, +) => { + const { key, createDocumentOptions = {} } = options; + + const documentData = await prisma.documentData.create({ + data: { + type: DocumentDataType.BYTES_64, + data: examplePdf, + initialData: examplePdf, + }, + }); + + const document = await prisma.document.create({ + data: { + title: `[TEST] Document ${key} - Completed`, + status: DocumentStatus.COMPLETED, + documentDataId: documentData.id, + userId: sender.id, + ...createDocumentOptions, + }, + }); + + for (const recipient of recipients) { + const email = typeof recipient === 'string' ? recipient : recipient.email; + const name = typeof recipient === 'string' ? recipient : recipient.name ?? ''; + + await prisma.recipient.create({ + data: { + email, + name, + token: nanoid(), + readStatus: ReadStatus.OPENED, + sendStatus: SendStatus.SENT, + signingStatus: SigningStatus.SIGNED, + signedAt: new Date(), + Document: { + connect: { + id: document.id, + }, + }, + Field: { + create: { + page: 1, + type: FieldType.NAME, + inserted: true, + customText: name, + positionX: new Prisma.Decimal(1), + positionY: new Prisma.Decimal(1), + width: new Prisma.Decimal(1), + height: new Prisma.Decimal(1), + documentId: document.id, + }, + }, + }, + }); + } +}; + +/** + * Create 5 team documents: + * - Completed document with 2 recipients. + * - Pending document with 1 recipient. + * - Pending document with 4 recipients. + * - Draft document with 3 recipients. + * - Draft document with 2 recipients. + * + * Create 3 non team documents where the user is a team member: + * - Completed document with 2 recipients. + * - Pending document with 1 recipient. + * - Draft document with 2 recipients. + * + * Create 3 non team documents where the user is not a team member, but the recipient is: + * - Completed document with 2 recipients. + * - Pending document with 1 recipient. + * - Draft document with 2 recipients. + * + * This should result in the following team document dashboard counts: + * - 0 Inbox + * - 2 Pending + * - 1 Completed + * - 2 Draft + * - 5 All + */ +export const seedTeamDocuments = async () => { + const team = await seedTeam({ + createTeamMembers: 4, + }); + + const documentOptions = { + teamId: team.id, + }; + + const teamMember1 = team.members[1].user; + const teamMember2 = team.members[2].user; + const teamMember3 = team.members[3].user; + const teamMember4 = team.members[4].user; + + const [testUser1, testUser2, testUser3, testUser4] = await Promise.all([ + seedUser(), + seedUser(), + seedUser(), + seedUser(), + ]); + + await seedDocuments([ + /** + * Team documents. + */ + { + sender: teamMember1, + recipients: [testUser1, testUser2], + type: DocumentStatus.COMPLETED, + documentOptions, + }, + { + sender: teamMember2, + recipients: [testUser1], + type: DocumentStatus.PENDING, + documentOptions, + }, + { + sender: teamMember2, + recipients: [testUser1, testUser2, testUser3, testUser4], + type: DocumentStatus.PENDING, + documentOptions, + }, + { + sender: teamMember2, + recipients: [testUser1, testUser2, teamMember1], + type: DocumentStatus.DRAFT, + documentOptions, + }, + { + sender: team.owner, + recipients: [testUser1, testUser2], + type: DocumentStatus.DRAFT, + documentOptions, + }, + /** + * Non team documents where the sender is a team member and recipient is not. + */ + { + sender: teamMember1, + recipients: [testUser1, testUser2], + type: DocumentStatus.COMPLETED, + }, + { + sender: teamMember2, + recipients: [testUser1], + type: DocumentStatus.PENDING, + }, + { + sender: teamMember3, + recipients: [testUser1, testUser2], + type: DocumentStatus.DRAFT, + }, + /** + * Non team documents where the sender is not a team member and recipient is. + */ + { + sender: testUser1, + recipients: [teamMember1, teamMember2], + type: DocumentStatus.COMPLETED, + }, + { + sender: testUser2, + recipients: [teamMember1], + type: DocumentStatus.PENDING, + }, + { + sender: testUser3, + recipients: [teamMember1, teamMember2], + type: DocumentStatus.DRAFT, + }, + ]); + + return { + team, + teamMember1, + teamMember2, + teamMember3, + teamMember4, + testUser1, + testUser2, + testUser3, + testUser4, + }; +}; diff --git a/packages/prisma/seed/teams.ts b/packages/prisma/seed/teams.ts new file mode 100644 index 000000000..99b0df8d5 --- /dev/null +++ b/packages/prisma/seed/teams.ts @@ -0,0 +1,177 @@ +import { prisma } from '..'; +import { TeamMemberInviteStatus, TeamMemberRole } from '../client'; +import { seedUser } from './users'; + +const EMAIL_DOMAIN = `test.documenso.com`; + +type SeedTeamOptions = { + createTeamMembers?: number; + createTeamEmail?: true | string; +}; + +export const seedTeam = async ({ + createTeamMembers = 0, + createTeamEmail, +}: SeedTeamOptions = {}) => { + const teamUrl = `team-${Date.now()}`; + const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail; + + const teamOwner = await seedUser({ + name: `${teamUrl}-original-owner`, + email: `${teamUrl}-original-owner@${EMAIL_DOMAIN}`, + }); + + const teamMembers = await Promise.all( + Array.from({ length: createTeamMembers }).map(async (_, i) => { + return seedUser({ + name: `${teamUrl}-member-${i + 1}`, + email: `${teamUrl}-member-${i + 1}@${EMAIL_DOMAIN}`, + }); + }), + ); + + const team = await prisma.team.create({ + data: { + name: teamUrl, + url: teamUrl, + ownerUserId: teamOwner.id, + members: { + createMany: { + data: [teamOwner, ...teamMembers].map((user) => ({ + userId: user.id, + role: TeamMemberRole.ADMIN, + })), + }, + }, + teamEmail: teamEmail + ? { + create: { + email: teamEmail, + name: teamEmail, + }, + } + : undefined, + }, + }); + + return await prisma.team.findFirstOrThrow({ + where: { + id: team.id, + }, + include: { + owner: true, + members: { + include: { + user: true, + }, + }, + teamEmail: true, + }, + }); +}; + +export const unseedTeam = async (teamUrl: string) => { + const team = await prisma.team.findUnique({ + where: { + url: teamUrl, + }, + include: { + members: true, + }, + }); + + if (!team) { + return; + } + + await prisma.team.delete({ + where: { + url: teamUrl, + }, + }); + + await prisma.user.deleteMany({ + where: { + id: { + in: team.members.map((member) => member.userId), + }, + }, + }); +}; + +export const seedTeamTransfer = async (options: { newOwnerUserId: number; teamId: number }) => { + return await prisma.teamTransferVerification.create({ + data: { + teamId: options.teamId, + token: Date.now().toString(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + userId: options.newOwnerUserId, + name: '', + email: '', + }, + }); +}; + +export const seedTeamEmail = async ({ email, teamId }: { email: string; teamId: number }) => { + return await prisma.teamEmail.create({ + data: { + name: email, + email, + teamId, + }, + }); +}; + +export const unseedTeamEmail = async ({ teamId }: { teamId: number }) => { + return await prisma.teamEmail.delete({ + where: { + teamId, + }, + }); +}; + +export const seedTeamInvite = async ({ + email, + teamId, + role = TeamMemberRole.ADMIN, +}: { + email: string; + teamId: number; + role?: TeamMemberRole; +}) => { + return await prisma.teamMemberInvite.create({ + data: { + email, + teamId, + role, + status: TeamMemberInviteStatus.PENDING, + token: Date.now().toString(), + }, + }); +}; + +export const seedTeamEmailVerification = async ({ + email, + teamId, +}: { + email: string; + teamId: number; +}) => { + return await prisma.teamEmailVerification.create({ + data: { + teamId, + email, + name: email, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), + token: Date.now().toString(), + }, + }); +}; + +export const unseedTeamEmailVerification = async ({ teamId }: { teamId: number }) => { + return await prisma.teamEmailVerification.delete({ + where: { + teamId, + }, + }); +}; diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts new file mode 100644 index 000000000..ce3858bc6 --- /dev/null +++ b/packages/prisma/seed/users.ts @@ -0,0 +1,34 @@ +import { hashSync } from '@documenso/lib/server-only/auth/hash'; + +import { prisma } from '..'; + +type SeedUserOptions = { + name?: string; + email?: string; + password?: string; + verified?: boolean; +}; + +export const seedUser = async ({ + name = `user-${Date.now()}`, + email = `user-${Date.now()}@test.documenso.com`, + password = 'password', + verified = true, +}: SeedUserOptions = {}) => { + return await prisma.user.create({ + data: { + name, + email, + password: hashSync(password), + emailVerified: verified ? new Date() : undefined, + }, + }); +}; + +export const unseedUser = async (userId: number) => { + await prisma.user.delete({ + where: { + id: userId, + }, + }); +}; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index 9dba63797..5940d971d 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -36,10 +36,8 @@ export const documentRouter = router({ .input(ZGetDocumentByIdQuerySchema) .query(async ({ input, ctx }) => { try { - const { id } = input; - return await getDocumentById({ - id, + ...input, userId: ctx.user.id, }); } catch (err) { @@ -73,9 +71,9 @@ export const documentRouter = router({ .input(ZCreateDocumentMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { title, documentDataId } = input; + const { title, documentDataId, teamId } = input; - const { remaining } = await getServerLimits({ email: ctx.user.email }); + const { remaining } = await getServerLimits({ email: ctx.user.email, teamId }); if (remaining.documents <= 0) { throw new TRPCError({ @@ -87,6 +85,7 @@ export const documentRouter = router({ return await createDocument({ userId: ctx.user.id, + teamId, title, documentDataId, }); @@ -245,12 +244,9 @@ export const documentRouter = router({ .input(ZResendDocumentMutationSchema) .mutation(async ({ input, ctx }) => { try { - const { documentId, recipients } = input; - return await resendDocument({ userId: ctx.user.id, - documentId, - recipients, + ...input, }); } catch (err) { console.error(err); @@ -266,14 +262,13 @@ export const documentRouter = router({ .input(ZGetDocumentByIdQuerySchema) .mutation(async ({ input, ctx }) => { try { - const { id } = input; - return await duplicateDocumentById({ - id, userId: ctx.user.id, + ...input, }); } catch (err) { console.log(err); + throw new TRPCError({ code: 'BAD_REQUEST', message: 'We are unable to duplicate this document. Please try again later.', diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index 5d8c23c27..f8d008f50 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -4,6 +4,7 @@ import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/clie export const ZGetDocumentByIdQuerySchema = z.object({ id: z.number().min(1), + teamId: z.number().min(1).optional(), }); export type TGetDocumentByIdQuerySchema = z.infer; @@ -17,6 +18,7 @@ export type TGetDocumentByTokenQuerySchema = z.infer; @@ -86,6 +88,7 @@ export type TSetPasswordForDocumentMutationSchema = z.infer< export const ZResendDocumentMutationSchema = z.object({ documentId: z.number(), recipients: z.array(z.number()).min(1), + teamId: z.number().min(1).optional(), }); export type TSendDocumentMutationSchema = z.infer; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 3ed2a0d05..aec70fd63 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -7,6 +7,7 @@ import { profileRouter } from './profile-router/router'; import { recipientRouter } from './recipient-router/router'; import { shareLinkRouter } from './share-link-router/router'; import { singleplayerRouter } from './singleplayer-router/router'; +import { teamRouter } from './team-router/router'; import { templateRouter } from './template-router/router'; import { router } from './trpc'; import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; @@ -21,8 +22,9 @@ export const appRouter = router({ admin: adminRouter, shareLink: shareLinkRouter, singleplayer: singleplayerRouter, - twoFactorAuthentication: twoFactorAuthenticationRouter, + team: teamRouter, template: templateRouter, + twoFactorAuthentication: twoFactorAuthenticationRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/team-router/router.ts b/packages/trpc/server/team-router/router.ts new file mode 100644 index 000000000..dd2032daf --- /dev/null +++ b/packages/trpc/server/team-router/router.ts @@ -0,0 +1,508 @@ +import { getTeamPrices } from '@documenso/ee/server-only/stripe/get-team-prices'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { acceptTeamInvitation } from '@documenso/lib/server-only/team/accept-team-invitation'; +import { createTeam } from '@documenso/lib/server-only/team/create-team'; +import { createTeamBillingPortal } from '@documenso/lib/server-only/team/create-team-billing-portal'; +import { createTeamPendingCheckoutSession } from '@documenso/lib/server-only/team/create-team-checkout-session'; +import { createTeamEmailVerification } from '@documenso/lib/server-only/team/create-team-email-verification'; +import { createTeamMemberInvites } from '@documenso/lib/server-only/team/create-team-member-invites'; +import { deleteTeam } from '@documenso/lib/server-only/team/delete-team'; +import { deleteTeamEmail } from '@documenso/lib/server-only/team/delete-team-email'; +import { deleteTeamEmailVerification } from '@documenso/lib/server-only/team/delete-team-email-verification'; +import { deleteTeamMemberInvitations } from '@documenso/lib/server-only/team/delete-team-invitations'; +import { deleteTeamMembers } from '@documenso/lib/server-only/team/delete-team-members'; +import { deleteTeamPending } from '@documenso/lib/server-only/team/delete-team-pending'; +import { deleteTeamTransferRequest } from '@documenso/lib/server-only/team/delete-team-transfer-request'; +import { findTeamInvoices } from '@documenso/lib/server-only/team/find-team-invoices'; +import { findTeamMemberInvites } from '@documenso/lib/server-only/team/find-team-member-invites'; +import { findTeamMembers } from '@documenso/lib/server-only/team/find-team-members'; +import { findTeams } from '@documenso/lib/server-only/team/find-teams'; +import { findTeamsPending } from '@documenso/lib/server-only/team/find-teams-pending'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import { getTeamEmailByEmail } from '@documenso/lib/server-only/team/get-team-email-by-email'; +import { getTeamInvitations } from '@documenso/lib/server-only/team/get-team-invitations'; +import { getTeamMembers } from '@documenso/lib/server-only/team/get-team-members'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; +import { leaveTeam } from '@documenso/lib/server-only/team/leave-team'; +import { requestTeamOwnershipTransfer } from '@documenso/lib/server-only/team/request-team-ownership-transfer'; +import { resendTeamEmailVerification } from '@documenso/lib/server-only/team/resend-team-email-verification'; +import { resendTeamMemberInvitation } from '@documenso/lib/server-only/team/resend-team-member-invitation'; +import { updateTeam } from '@documenso/lib/server-only/team/update-team'; +import { updateTeamEmail } from '@documenso/lib/server-only/team/update-team-email'; +import { updateTeamMember } from '@documenso/lib/server-only/team/update-team-member'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZAcceptTeamInvitationMutationSchema, + ZCreateTeamBillingPortalMutationSchema, + ZCreateTeamEmailVerificationMutationSchema, + ZCreateTeamMemberInvitesMutationSchema, + ZCreateTeamMutationSchema, + ZCreateTeamPendingCheckoutMutationSchema, + ZDeleteTeamEmailMutationSchema, + ZDeleteTeamEmailVerificationMutationSchema, + ZDeleteTeamMemberInvitationsMutationSchema, + ZDeleteTeamMembersMutationSchema, + ZDeleteTeamMutationSchema, + ZDeleteTeamPendingMutationSchema, + ZDeleteTeamTransferRequestMutationSchema, + ZFindTeamInvoicesQuerySchema, + ZFindTeamMemberInvitesQuerySchema, + ZFindTeamMembersQuerySchema, + ZFindTeamsPendingQuerySchema, + ZFindTeamsQuerySchema, + ZGetTeamMembersQuerySchema, + ZGetTeamQuerySchema, + ZLeaveTeamMutationSchema, + ZRequestTeamOwnerhsipTransferMutationSchema, + ZResendTeamEmailVerificationMutationSchema, + ZResendTeamMemberInvitationMutationSchema, + ZUpdateTeamEmailMutationSchema, + ZUpdateTeamMemberMutationSchema, + ZUpdateTeamMutationSchema, +} from './schema'; + +export const teamRouter = router({ + acceptTeamInvitation: authenticatedProcedure + .input(ZAcceptTeamInvitationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await acceptTeamInvitation({ + teamId: input.teamId, + userId: ctx.user.id, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createBillingPortal: authenticatedProcedure + .input(ZCreateTeamBillingPortalMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamBillingPortal({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeam: authenticatedProcedure + .input(ZCreateTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeamEmailVerification: authenticatedProcedure + .input(ZCreateTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamEmailVerification({ + teamId: input.teamId, + userId: ctx.user.id, + data: { + email: input.email, + name: input.name, + }, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeamMemberInvites: authenticatedProcedure + .input(ZCreateTeamMemberInvitesMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamMemberInvites({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createTeamPendingCheckout: authenticatedProcedure + .input(ZCreateTeamPendingCheckoutMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createTeamPendingCheckoutSession({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeam: authenticatedProcedure + .input(ZDeleteTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamEmail: authenticatedProcedure + .input(ZDeleteTeamEmailMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamEmail({ + userId: ctx.user.id, + userEmail: ctx.user.email, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamEmailVerification: authenticatedProcedure + .input(ZDeleteTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamEmailVerification({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamMemberInvitations: authenticatedProcedure + .input(ZDeleteTeamMemberInvitationsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamMemberInvitations({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamMembers: authenticatedProcedure + .input(ZDeleteTeamMembersMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamMembers({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamPending: authenticatedProcedure + .input(ZDeleteTeamPendingMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamPending({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteTeamTransferRequest: authenticatedProcedure + .input(ZDeleteTeamTransferRequestMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteTeamTransferRequest({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamInvoices: authenticatedProcedure + .input(ZFindTeamInvoicesQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamInvoices({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamMemberInvites: authenticatedProcedure + .input(ZFindTeamMemberInvitesQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamMemberInvites({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamMembers: authenticatedProcedure + .input(ZFindTeamMembersQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamMembers({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeams: authenticatedProcedure.input(ZFindTeamsQuerySchema).query(async ({ input, ctx }) => { + try { + return await findTeams({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findTeamsPending: authenticatedProcedure + .input(ZFindTeamsPendingQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findTeamsPending({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeam: authenticatedProcedure.input(ZGetTeamQuerySchema).query(async ({ input, ctx }) => { + try { + return await getTeamById({ teamId: input.teamId, userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamEmailByEmail: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeamEmailByEmail({ email: ctx.user.email }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamInvitations: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeamInvitations({ email: ctx.user.email }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamMembers: authenticatedProcedure + .input(ZGetTeamMembersQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await getTeamMembers({ teamId: input.teamId, userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeamPrices: authenticatedProcedure.query(async () => { + try { + return await getTeamPrices(); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + getTeams: authenticatedProcedure.query(async ({ ctx }) => { + try { + return await getTeams({ userId: ctx.user.id }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + leaveTeam: authenticatedProcedure + .input(ZLeaveTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await leaveTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeam: authenticatedProcedure + .input(ZUpdateTeamMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeam({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeamEmail: authenticatedProcedure + .input(ZUpdateTeamEmailMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeamEmail({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateTeamMember: authenticatedProcedure + .input(ZUpdateTeamMemberMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateTeamMember({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + requestTeamOwnershipTransfer: authenticatedProcedure + .input(ZRequestTeamOwnerhsipTransferMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await requestTeamOwnershipTransfer({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + resendTeamEmailVerification: authenticatedProcedure + .input(ZResendTeamEmailVerificationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + await resendTeamEmailVerification({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + resendTeamMemberInvitation: authenticatedProcedure + .input(ZResendTeamMemberInvitationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + await resendTeamMemberInvitation({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), +}); diff --git a/packages/trpc/server/team-router/schema.ts b/packages/trpc/server/team-router/schema.ts new file mode 100644 index 000000000..953b12490 --- /dev/null +++ b/packages/trpc/server/team-router/schema.ts @@ -0,0 +1,213 @@ +import { z } from 'zod'; + +import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; +import { TeamMemberRole } from '@documenso/prisma/client'; + +const GenericFindQuerySchema = z.object({ + term: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional(), +}); + +/** + * Restrict team URLs schema. + * + * Allowed characters: + * - Alphanumeric + * - Lowercase + * - Dashes + * - Underscores + * + * Conditions: + * - 3-30 characters + * - Cannot start and end with underscores or dashes. + * - Cannot contain consecutive underscores or dashes. + * - Cannot be a reserved URL in the PROTECTED_TEAM_URLS list + */ +export const ZTeamUrlSchema = z + .string() + .trim() + .min(3, { message: 'Team URL must be at least 3 characters long.' }) + .max(30, { message: 'Team URL must not exceed 30 characters.' }) + .toLowerCase() + .regex(/^[a-z0-9].*[^_-]$/, 'Team URL cannot start or end with dashes or underscores.') + .regex(/^(?!.*[-_]{2})/, 'Team URL cannot contain consecutive dashes or underscores.') + .regex( + /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/, + 'Team URL can only contain letters, numbers, dashes and underscores.', + ) + .refine((value) => !PROTECTED_TEAM_URLS.includes(value), { + message: 'This URL is already in use.', + }); + +export const ZTeamNameSchema = z + .string() + .trim() + .min(3, { message: 'Team name must be at least 3 characters long.' }) + .max(30, { message: 'Team name must not exceed 30 characters.' }); + +export const ZAcceptTeamInvitationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZCreateTeamBillingPortalMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZCreateTeamMutationSchema = z.object({ + teamName: ZTeamNameSchema, + teamUrl: ZTeamUrlSchema, +}); + +export const ZCreateTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + email: z.string().trim().email().toLowerCase().min(1, 'Please enter a valid email.'), +}); + +export const ZCreateTeamMemberInvitesMutationSchema = z.object({ + teamId: z.number(), + invitations: z.array( + z.object({ + email: z.string().email().toLowerCase(), + role: z.nativeEnum(TeamMemberRole), + }), + ), +}); + +export const ZCreateTeamPendingCheckoutMutationSchema = z.object({ + interval: z.union([z.literal('monthly'), z.literal('yearly')]), + pendingTeamId: z.number(), +}); + +export const ZDeleteTeamEmailMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamMembersMutationSchema = z.object({ + teamId: z.number(), + teamMemberIds: z.array(z.number()), +}); + +export const ZDeleteTeamMemberInvitationsMutationSchema = z.object({ + teamId: z.number(), + invitationIds: z.array(z.number()), +}); + +export const ZDeleteTeamMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZDeleteTeamPendingMutationSchema = z.object({ + pendingTeamId: z.number(), +}); + +export const ZDeleteTeamTransferRequestMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZFindTeamInvoicesQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZFindTeamMemberInvitesQuerySchema = GenericFindQuerySchema.extend({ + teamId: z.number(), +}); + +export const ZFindTeamMembersQuerySchema = GenericFindQuerySchema.extend({ + teamId: z.number(), +}); + +export const ZFindTeamsQuerySchema = GenericFindQuerySchema; + +export const ZFindTeamsPendingQuerySchema = GenericFindQuerySchema; + +export const ZGetTeamQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZGetTeamMembersQuerySchema = z.object({ + teamId: z.number(), +}); + +export const ZLeaveTeamMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZUpdateTeamMutationSchema = z.object({ + teamId: z.number(), + data: z.object({ + name: ZTeamNameSchema, + url: ZTeamUrlSchema, + }), +}); + +export const ZUpdateTeamEmailMutationSchema = z.object({ + teamId: z.number(), + data: z.object({ + name: z.string().trim().min(1), + }), +}); + +export const ZUpdateTeamMemberMutationSchema = z.object({ + teamId: z.number(), + teamMemberId: z.number(), + data: z.object({ + role: z.nativeEnum(TeamMemberRole), + }), +}); + +export const ZRequestTeamOwnerhsipTransferMutationSchema = z.object({ + teamId: z.number(), + newOwnerUserId: z.number(), + clearPaymentMethods: z.boolean(), +}); + +export const ZResendTeamEmailVerificationMutationSchema = z.object({ + teamId: z.number(), +}); + +export const ZResendTeamMemberInvitationMutationSchema = z.object({ + teamId: z.number(), + invitationId: z.number(), +}); + +export type TCreateTeamMutationSchema = z.infer; +export type TCreateTeamEmailVerificationMutationSchema = z.infer< + typeof ZCreateTeamEmailVerificationMutationSchema +>; +export type TCreateTeamMemberInvitesMutationSchema = z.infer< + typeof ZCreateTeamMemberInvitesMutationSchema +>; +export type TCreateTeamPendingCheckoutMutationSchema = z.infer< + typeof ZCreateTeamPendingCheckoutMutationSchema +>; +export type TDeleteTeamEmailMutationSchema = z.infer; +export type TDeleteTeamMembersMutationSchema = z.infer; +export type TDeleteTeamMutationSchema = z.infer; +export type TDeleteTeamPendingMutationSchema = z.infer; +export type TDeleteTeamTransferRequestMutationSchema = z.infer< + typeof ZDeleteTeamTransferRequestMutationSchema +>; +export type TFindTeamMemberInvitesQuerySchema = z.infer; +export type TFindTeamMembersQuerySchema = z.infer; +export type TFindTeamsQuerySchema = z.infer; +export type TFindTeamsPendingQuerySchema = z.infer; +export type TGetTeamQuerySchema = z.infer; +export type TGetTeamMembersQuerySchema = z.infer; +export type TLeaveTeamMutationSchema = z.infer; +export type TUpdateTeamMutationSchema = z.infer; +export type TUpdateTeamEmailMutationSchema = z.infer; +export type TRequestTeamOwnerhsipTransferMutationSchema = z.infer< + typeof ZRequestTeamOwnerhsipTransferMutationSchema +>; +export type TResendTeamEmailVerificationMutationSchema = z.infer< + typeof ZResendTeamEmailVerificationMutationSchema +>; +export type TResendTeamMemberInvitationMutationSchema = z.infer< + typeof ZResendTeamMemberInvitationMutationSchema +>; diff --git a/packages/ui/components/animate/animate-generic-fade-in-out.tsx b/packages/ui/components/animate/animate-generic-fade-in-out.tsx new file mode 100644 index 000000000..5f57c96df --- /dev/null +++ b/packages/ui/components/animate/animate-generic-fade-in-out.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { motion } from 'framer-motion'; + +type AnimateGenericFadeInOutProps = { + children: React.ReactNode; + className?: string; +}; + +export const AnimateGenericFadeInOut = ({ children, className }: AnimateGenericFadeInOutProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 34675ba89..44d14cb82 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -35,7 +35,7 @@ "@radix-ui/react-checkbox": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.2", "@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-hover-card": "^1.0.5", "@radix-ui/react-label": "^2.0.1", @@ -45,7 +45,7 @@ "@radix-ui/react-progress": "^1.0.2", "@radix-ui/react-radio-group": "^1.1.2", "@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-slider": "^1.1.1", "@radix-ui/react-slot": "^1.0.2", diff --git a/packages/ui/primitives/avatar.tsx b/packages/ui/primitives/avatar.tsx index 0039ad4eb..c80e3a658 100644 --- a/packages/ui/primitives/avatar.tsx +++ b/packages/ui/primitives/avatar.tsx @@ -48,4 +48,37 @@ const AvatarFallback = React.forwardRef< AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; -export { Avatar, AvatarImage, AvatarFallback }; +type AvatarWithTextProps = { + avatarClass?: string; + avatarFallback: string; + className?: string; + primaryText: React.ReactNode; + secondaryText?: React.ReactNode; + rightSideComponent?: React.ReactNode; +}; + +const AvatarWithText = ({ + avatarClass, + avatarFallback, + className, + primaryText, + secondaryText, + rightSideComponent, +}: AvatarWithTextProps) => ( +
+ + {avatarFallback} + + +
+ {primaryText} + {secondaryText} +
+ + {rightSideComponent} +
+); + +export { Avatar, AvatarImage, AvatarFallback, AvatarWithText }; diff --git a/packages/ui/primitives/badge.tsx b/packages/ui/primitives/badge.tsx index 1ff153f79..fd56bc1ce 100644 --- a/packages/ui/primitives/badge.tsx +++ b/packages/ui/primitives/badge.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { VariantProps, cva } from 'class-variance-authority'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; import { cn } from '../lib/utils'; diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 5754b35a5..5fc3fc1bb 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -18,6 +18,7 @@ const buttonVariants = cva( secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground', link: 'underline-offset-4 hover:underline text-primary', + none: '', }, size: { default: 'h-10 py-2 px-4', diff --git a/packages/ui/primitives/command.tsx b/packages/ui/primitives/command.tsx index 65f88fc4e..fee5321cd 100644 --- a/packages/ui/primitives/command.tsx +++ b/packages/ui/primitives/command.tsx @@ -92,7 +92,7 @@ const CommandGroup = React.forwardRef< ) => (
); diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 74764df80..9c8db7918 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -403,7 +403,7 @@ export const AddFieldsFormPartial = ({ {recipients.map((recipient) => ( { @@ -439,7 +439,7 @@ export const AddFieldsFormPartial = ({ ) : ( - + diff --git a/packages/ui/primitives/multi-select-combobox.tsx b/packages/ui/primitives/multi-select-combobox.tsx new file mode 100644 index 000000000..62e5fa2cf --- /dev/null +++ b/packages/ui/primitives/multi-select-combobox.tsx @@ -0,0 +1,165 @@ +'use client'; + +import * as React from 'react'; + +import { AnimatePresence } from 'framer-motion'; +import { Check, ChevronsUpDown, Loader, XIcon } from 'lucide-react'; + +import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; + +import { cn } from '../lib/utils'; +import { Button } from './button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command'; +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +type OptionValue = string | number | boolean | null; + +type ComboBoxOption = { + label: string; + value: T; + disabled?: boolean; +}; + +type MultiSelectComboboxProps = { + emptySelectionPlaceholder?: React.ReactNode | string; + enableClearAllButton?: boolean; + loading?: boolean; + inputPlaceholder?: string; + onChange: (_values: T[]) => void; + options: ComboBoxOption[]; + selectedValues: T[]; +}; + +/** + * Multi select combo box component which supports: + * + * - Label/value pairs + * - Loading state + * - Clear all button + */ +export function MultiSelectCombobox({ + emptySelectionPlaceholder = 'Select values...', + enableClearAllButton, + inputPlaceholder, + loading, + onChange, + options, + selectedValues, +}: MultiSelectComboboxProps) { + const [open, setOpen] = React.useState(false); + + const handleSelect = (selectedOption: T) => { + let newSelectedOptions = [...selectedValues, selectedOption]; + + if (selectedValues.includes(selectedOption)) { + newSelectedOptions = selectedValues.filter((v) => v !== selectedOption); + } + + onChange(newSelectedOptions); + + setOpen(false); + }; + + const selectedOptions = React.useMemo(() => { + return selectedValues.map((value): ComboBoxOption => { + const foundOption = options.find((option) => option.value === value); + + if (foundOption) { + return foundOption; + } + + let label = ''; + + if (typeof value === 'string' || typeof value === 'number') { + label = value.toString(); + } + + return { + label, + value, + }; + }); + }, [selectedValues, options]); + + const buttonLabel = React.useMemo(() => { + if (loading) { + return ''; + } + + if (selectedOptions.length === 0) { + return emptySelectionPlaceholder; + } + + return selectedOptions.map((option) => option.label).join(', '); + }, [selectedOptions, emptySelectionPlaceholder, loading]); + + const showClearButton = enableClearAllButton && selectedValues.length > 0; + + return ( + +
+ + + + + {/* This is placed outside the trigger since we can't have nested buttons. */} + {showClearButton && !loading && ( +
+ +
+ )} +
+ + + + + No value found. + + {options.map((option, i) => ( + handleSelect(option.value)}> + + {option.label} + + ))} + + + +
+ ); +} diff --git a/turbo.json b/turbo.json index b0a7a0fc6..4ea966a4d 100644 --- a/turbo.json +++ b/turbo.json @@ -43,7 +43,6 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID", - "NEXT_PUBLIC_STRIPE_FREE_PLAN_ID", "NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", "NEXT_PRIVATE_DATABASE_URL",