diff --git a/apps/web/src/app/(dashboard)/settings/organisations/page.tsx b/apps/web/src/app/(dashboard)/settings/organisations/page.tsx new file mode 100644 index 000000000..cdcafd49c --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/organisations/page.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { CreateOrganisationDialog } from '~/components/(organisations)/dialogs/create-organisation-dialog'; +import { CurrentUserOrganisationsDataTable } from '~/components/(organisations)/tables/current-user-organisations-data-table'; + +import { TeamInvitations } from './team-invitations'; + +export default function OrganisationsSettingsPage() { + return ( +
+ + {/* Todo: Org - only display when no org created & user can create org */} + + + + + +
+ {/* Todo: Orgs */} + +
+
+ ); +} diff --git a/apps/web/src/app/(orgs)/orgs/[orgUrl]/layout.tsx b/apps/web/src/app/(orgs)/orgs/[orgUrl]/layout.tsx new file mode 100644 index 000000000..4b632a461 --- /dev/null +++ b/apps/web/src/app/(orgs)/orgs/[orgUrl]/layout.tsx @@ -0,0 +1,67 @@ +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 { getOrganisationByUrl } from '@documenso/lib/server-only/organisation/get-organisation'; +import { getTeams } from '@documenso/lib/server-only/team/get-teams'; + +import { Header } from '~/components/(dashboard)/layout/header'; +import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; +import { NextAuthProvider } from '~/providers/next-auth'; +import { OrganisationProvider } from '~/providers/organisation'; + +export type AuthenticatedOrganisationLayoutProps = { + children: React.ReactNode; + params: { + orgUrl: string; + }; +}; + +export default async function AuthenticatedOrganisationLayout({ + children, + params, +}: AuthenticatedOrganisationLayoutProps) { + const { session, user } = await getServerComponentSession(); + + if (!session || !user) { + redirect('/signin'); + } + + const [getTeamsPromise, getOrganisationPromise] = await Promise.allSettled([ + getTeams({ userId: user.id }), // Todo: Orgs + getOrganisationByUrl({ userId: user.id, organisationUrl: params.orgUrl }), + ]); + + if (getOrganisationPromise.status === 'rejected') { + redirect('/documents', RedirectType.replace); + } + + const organisation = getOrganisationPromise.value; + const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : []; + + return ( + + {/* Todo: Orgs don't need limits... right? */} + + {/* {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && ( + + )} */} + + {/* Todo: Orgs - Should we scope teams to orgs? */} +
+ + +
{children}
+
+ + + + + ); +} diff --git a/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/layout.tsx b/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/layout.tsx new file mode 100644 index 000000000..56da81907 --- /dev/null +++ b/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/layout.tsx @@ -0,0 +1,57 @@ +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 { getOrganisationByUrl } from '@documenso/lib/server-only/organisation/get-organisation'; +import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; + +import { DesktopNav } from '~/components/(organisations)/settings/layout/desktop-nav'; +import { MobileNav } from '~/components/(organisations)/settings/layout/mobile-nav'; + +export type OrganisationSettingsLayoutProps = { + children: React.ReactNode; + params: { + orgUrl: string; + }; +}; + +export default async function OrganisationSettingsLayout({ + children, + params: { orgUrl }, +}: OrganisationSettingsLayoutProps) { + const session = await getRequiredServerComponentSession(); + + try { + const organisation = await getOrganisationByUrl({ + userId: session.user.id, + organisationUrl: orgUrl, + }); + + if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentMember.role)) { + throw new Error(AppErrorCode.UNAUTHORIZED); + } + } catch (e) { + const error = AppError.parseError(e); + + if (error.code === 'P2025') { + notFound(); + } + + throw e; + } + + return ( +
+

Organisation Settings

+ +
+ + + +
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/members/page.tsx b/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/members/page.tsx new file mode 100644 index 000000000..3c39140e8 --- /dev/null +++ b/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/members/page.tsx @@ -0,0 +1,43 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getOrganisationByUrl } from '@documenso/lib/server-only/organisation/get-organisation'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { InviteOrganisationMembersDialog } from '~/components/(organisations)/dialogs/invite-organisation-member-dialog'; +import { OrganisationMemberPageDataTable } from '~/components/(organisations)/tables/organisation-member-page-data-table'; + +export type OrganisationSettingsMembersPageProps = { + params: { + orgUrl: string; + }; +}; + +export default async function OrganisationSettingsMembersPage({ + params, +}: OrganisationSettingsMembersPageProps) { + const { orgUrl } = params; + + const session = await getRequiredServerComponentSession(); + + const organisation = await getOrganisationByUrl({ + userId: session.user.id, + organisationUrl: orgUrl, + }); + + return ( +
+ + + + + +
+ ); +} diff --git a/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/page.tsx b/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/page.tsx new file mode 100644 index 000000000..268799af3 --- /dev/null +++ b/apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/page.tsx @@ -0,0 +1,57 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getOrganisationByUrl } from '@documenso/lib/server-only/organisation/get-organisation'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; + +import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; +import { UpdateOrganisationForm } from '~/components/(organisations)/forms/update-organisation-form'; + +export type OrganisationSettingsPageProps = { + params: { + orgUrl: string; + }; +}; + +export default async function OrganisationSettingsPage({ params }: OrganisationSettingsPageProps) { + const { orgUrl } = params; + + const session = await getRequiredServerComponentSession(); + + const organisation = await getOrganisationByUrl({ + userId: session.user.id, + organisationUrl: orgUrl, + }); + + return ( +
+ + + + +
+ +
+ Transfer or delete organisation + + + Please contact us at{' '} + + support@documenso.com + {' '} + to transfer or delete your organisation + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(unauthenticated)/organisation/invite/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/organisation/invite/[token]/page.tsx new file mode 100644 index 000000000..63d4e0e39 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/organisation/invite/[token]/page.tsx @@ -0,0 +1,127 @@ +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 { acceptOrganisationInvitation } from '@documenso/lib/server-only/organisation/accept-organisation-invitation'; +import { getOrganisationById } from '@documenso/lib/server-only/organisation/get-organisation'; +import { prisma } from '@documenso/prisma'; +import { InviteStatus } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; + +type AcceptInvitationPageProps = { + params: { + token: string; + }; +}; + +export default async function AcceptOrganisationInvitationPage({ + params: { token }, +}: AcceptInvitationPageProps) { + const session = await getServerComponentSession(); + + const organisationMemberInvite = await prisma.organisationMemberInvite.findUnique({ + where: { + token, + }, + }); + + if (!organisationMemberInvite) { + return ( +
+
+

Invalid token

+ +

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

+ + +
+
+ ); + } + + const organisation = await getOrganisationById({ + organisationId: organisationMemberInvite.organisationId, + }); + + const user = await prisma.user.findFirst({ + where: { + email: { + equals: organisationMemberInvite.email, + mode: 'insensitive', + }, + }, + }); + + // Directly convert the organisation member invite to a organisation member if they already have an account. + if (user) { + await acceptOrganisationInvitation({ userId: user.id, organisationId: organisation.id }); + } + + // For users who do not exist yet, set the organisation invite status to accepted, which is checked during + // user creation to determine if we should add the user to the organisation at that time. + if (!user && organisationMemberInvite.status !== InviteStatus.ACCEPTED) { + await prisma.organisationMemberInvite.update({ + where: { + id: organisationMemberInvite.id, + }, + data: { + status: InviteStatus.ACCEPTED, + }, + }); + } + + const email = encryptSecondaryData({ + data: organisationMemberInvite.email, + expiresAt: DateTime.now().plus({ days: 1 }).toMillis(), + }); + + if (!user) { + return ( +
+

Organisation invitation

+ +

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

+ +

+ 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 {organisation.name} to join their + organisation. +

+ + {isSessionUserTheInvitedUser ? ( + + ) : ( + + )} +
+ ); +} 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 94e366e27..6046fc4a2 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react'; +import { Braces, Building, CreditCard, Lock, User, Users, Webhook } 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) => { + + + + + + + + + )} + + + + + Create organisation + + + Create an organisation to collaborate with teams. + + + +
+ +
+ ( + + Organisation Name + + { + const oldGeneratedUrl = mapTextToUrl(field.value); + const newGeneratedUrl = mapTextToUrl(event.target.value); + + const urlField = form.getValues('organisationUrl'); + if (urlField === oldGeneratedUrl) { + form.setValue('organisationUrl', newGeneratedUrl); + } + + field.onChange(event); + }} + /> + + + + )} + /> + + ( + + Organisation URL + + + + {!form.formState.errors.organisationUrl && ( + + {field.value + ? `${WEBAPP_BASE_URL}/orgs/${field.value}` + : 'A unique URL to identify your organisation'} + + )} + + + + )} + /> + + + + + + +
+
+ +
+ + ); +}; diff --git a/apps/web/src/components/(organisations)/dialogs/delete-organisation-member-dialog.tsx b/apps/web/src/components/(organisations)/dialogs/delete-organisation-member-dialog.tsx new file mode 100644 index 000000000..ed587e259 --- /dev/null +++ b/apps/web/src/components/(organisations)/dialogs/delete-organisation-member-dialog.tsx @@ -0,0 +1,114 @@ +'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 DeleteOrganisationMemberDialogProps = { + organisationId: string; + organisationName: string; + organisationMemberId: string; + organisationMemberName: string; + organisationMemberEmail: string; + trigger?: React.ReactNode; +}; + +export const DeleteOrganisationMemberDialog = ({ + trigger, + organisationId, + organisationName, + organisationMemberId, + organisationMemberName, + organisationMemberEmail, +}: DeleteOrganisationMemberDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + // Todo: Orgs - Add logic so we can't remove members who are owning teams. + + const { mutateAsync: deleteOrganisationMembers, isLoading: isDeletingOrganisationMember } = + trpc.organisation.deleteOrganisationMembers.useMutation({ + onSuccess: () => { + toast({ + title: 'Success', + description: 'You have successfully removed this user from the organisation.', + 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 ( + !isDeletingOrganisationMember && setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to remove the following user from{' '} + {organisationName}. + + + + + {organisationMemberName}} + secondaryText={organisationMemberEmail} + /> + + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/web/src/components/(organisations)/dialogs/invite-organisation-member-dialog.tsx b/apps/web/src/components/(organisations)/dialogs/invite-organisation-member-dialog.tsx new file mode 100644 index 000000000..8ff3417ca --- /dev/null +++ b/apps/web/src/components/(organisations)/dialogs/invite-organisation-member-dialog.tsx @@ -0,0 +1,248 @@ +'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 { + ORGANISATION_MEMBER_ROLE_HIERARCHY, + ORGANISATION_MEMBER_ROLE_MAP, +} from '@documenso/lib/constants/organisations'; +import { OrganisationMemberRole } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { ZCreateOrganisationMemberInvitesMutationSchema } from '@documenso/trpc/server/organisation-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 InviteOrganisationMembersDialogProps = { + currentUserRole: OrganisationMemberRole; + organisationId: string; + trigger?: React.ReactNode; +} & Omit; + +const ZInviteOrganisationMembersFormSchema = z + .object({ + invitations: ZCreateOrganisationMemberInvitesMutationSchema.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 TInviteOrganisationMembersFormSchema = z.infer; + +export const InviteOrganisationMembersDialog = ({ + currentUserRole, + organisationId, + trigger, + ...props +}: InviteOrganisationMembersDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZInviteOrganisationMembersFormSchema), + defaultValues: { + invitations: [ + { + email: '', + role: OrganisationMemberRole.MEMBER, + }, + ], + }, + }); + + const { + append: appendOrganisationMemberInvite, + fields: organisationMemberInvites, + remove: removeOrganisationMemberInvite, + } = useFieldArray({ + control: form.control, + name: 'invitations', + }); + + const { mutateAsync: createOrganisationMemberInvites } = + trpc.organisation.createOrganisationMemberInvites.useMutation(); + + const onAddOrganisationMemberInvite = () => { + appendOrganisationMemberInvite({ + email: '', + role: OrganisationMemberRole.MEMBER, + }); + }; + + const onFormSubmit = async ({ invitations }: TInviteOrganisationMembersFormSchema) => { + try { + await createOrganisationMemberInvites({ + organisationId, + invitations, + }); + + toast({ + title: 'Success', + description: 'Organisation 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 organisation members. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Invite organisation members + + + An email containing an invitation will be sent to each member. + + + +
+ +
+ {organisationMemberInvites.map((organisationMemberInvite, index) => ( +
+ ( + + {index === 0 && Email address} + + + + + + )} + /> + + ( + + {index === 0 && Role} + + + + + + )} + /> + + +
+ ))} + + + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/components/(organisations)/dialogs/leave-organisation-dialog.tsx b/apps/web/src/components/(organisations)/dialogs/leave-organisation-dialog.tsx new file mode 100644 index 000000000..7cf3e4cda --- /dev/null +++ b/apps/web/src/components/(organisations)/dialogs/leave-organisation-dialog.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useState } from 'react'; + +import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations'; +import { AppError } from '@documenso/lib/errors/app-error'; +import type { OrganisationMemberRole } 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 LeaveOrganisationDialogProps = { + organisationId: string; + organisationName: string; + role: OrganisationMemberRole; + trigger?: React.ReactNode; +}; + +export const LeaveOrganisationDialog = ({ + trigger, + organisationId, + organisationName, + role, +}: LeaveOrganisationDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const [errorCode, setErrorCode] = useState(null); + + const { mutateAsync: leaveOrg, isLoading: isLeavingOrg } = + trpc.organisation.leaveOrganisation.useMutation({ + onMutate: () => { + setErrorCode(null); + }, + onSuccess: () => { + toast({ + title: 'Success', + description: 'You have successfully left this organisation.', + duration: 5000, + }); + + setOpen(false); + }, + onError: (err) => { + const error = AppError.parseError(err); + + setErrorCode(error.code); + }, + }); + + return ( + !isLeavingOrg && setOpen(value)}> + + {trigger ?? } + + + + + Are you sure? + + + You are about to leave the following organisation. + + + + + + + + {errorCode && ( + + {errorCode === 'USER_HAS_TEAMS' + ? 'You cannot leave an organisation if you are the owner of a team in it.' + : 'We encountered an unknown error while attempting to leave this organisation. Please try again later.'} + + )} + +
+ + + + + +
+
+
+ ); +}; diff --git a/apps/web/src/components/(organisations)/dialogs/update-organisation-member-dialog.tsx b/apps/web/src/components/(organisations)/dialogs/update-organisation-member-dialog.tsx new file mode 100644 index 000000000..47296d398 --- /dev/null +++ b/apps/web/src/components/(organisations)/dialogs/update-organisation-member-dialog.tsx @@ -0,0 +1,189 @@ +'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 { + ORGANISATION_MEMBER_ROLE_HIERARCHY, + ORGANISATION_MEMBER_ROLE_MAP, +} from '@documenso/lib/constants/organisations'; +import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; +import { OrganisationMemberRole } 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 UpdateOrganisationMemberDialogProps = { + currentUserRole: OrganisationMemberRole; + trigger?: React.ReactNode; + organisationId: string; + organisationMemberId: string; + organisationMemberName: string; + organisationMemberRole: OrganisationMemberRole; +} & Omit; + +const ZUpdateOrganisationMemberFormSchema = z.object({ + role: z.nativeEnum(OrganisationMemberRole), +}); + +type ZUpdateOrganisationMemberSchema = z.infer; + +export const UpdateOrganisationMemberDialog = ({ + currentUserRole, + trigger, + organisationId, + organisationMemberId, + organisationMemberName, + organisationMemberRole, + ...props +}: UpdateOrganisationMemberDialogProps) => { + const [open, setOpen] = useState(false); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateOrganisationMemberFormSchema), + defaultValues: { + role: organisationMemberRole, + }, + }); + + const { mutateAsync: updateOrganisationMember } = + trpc.organisation.updateOrganisationMember.useMutation(); + + const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => { + try { + await updateOrganisationMember({ + organisationId, + organisationMemberId, + data: { + role, + }, + }); + + toast({ + title: 'Success', + description: `You have updated ${organisationMemberName}.`, + duration: 5000, + }); + + setOpen(false); + } catch { + toast({ + title: 'An unknown error occurred', + variant: 'destructive', + description: + 'We encountered an unknown error while attempting to update this organisation member. Please try again later.', + }); + } + }; + + useEffect(() => { + if (!open) { + return; + } + + form.reset(); + + if (!isOrganisationRoleWithinUserHierarchy(currentUserRole, organisationMemberRole)) { + setOpen(false); + + toast({ + title: 'You cannot modify a organisation member who has a higher role than you.', + variant: 'destructive', + }); + } + }, [open, currentUserRole, organisationMemberRole, form, toast]); + + return ( + !form.formState.isSubmitting && setOpen(value)} + > + e.stopPropagation()} asChild> + {trigger ?? } + + + + + Update organisation member + + + You are currently updating {organisationMemberName}. + + + +
+ +
+ ( + + Role + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/web/src/components/(organisations)/forms/update-organisation-form.tsx b/apps/web/src/components/(organisations)/forms/update-organisation-form.tsx new file mode 100644 index 000000000..fe2b8881f --- /dev/null +++ b/apps/web/src/components/(organisations)/forms/update-organisation-form.tsx @@ -0,0 +1,177 @@ +'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 { ZUpdateOrganisationMutationSchema } from '@documenso/trpc/server/organisation-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 UpdateOrganisationDialogProps = { + organisationId: string; + organisationName: string; + organisationUrl: string; +}; + +const ZUpdateOrganisationFormSchema = ZUpdateOrganisationMutationSchema.shape.data.pick({ + name: true, + url: true, +}); + +type TUpdateOrganisationFormSchema = z.infer; + +export const UpdateOrganisationForm = ({ + organisationId, + organisationName, + organisationUrl, +}: UpdateOrganisationDialogProps) => { + const router = useRouter(); + + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(ZUpdateOrganisationFormSchema), + defaultValues: { + name: organisationName, + url: organisationUrl, + }, + }); + + const { mutateAsync: updateOrganisation } = trpc.organisation.updateOrganisation.useMutation(); + + const onFormSubmit = async ({ name, url }: TUpdateOrganisationFormSchema) => { + try { + await updateOrganisation({ + data: { + name, + url, + }, + organisationId, + }); + + toast({ + title: 'Success', + description: 'Your organisation has been successfully updated.', + duration: 5000, + }); + + form.reset({ + name, + url, + }); + + if (url !== organisationUrl) { + router.push(`${WEBAPP_BASE_URL}/orgs/${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 organisation. Please try again later.', + }); + } + }; + + return ( +
+ +
+ ( + + Organisation Name + + + + + + )} + /> + + ( + + Organisation URL + + + + {!form.formState.errors.url && ( + + {field.value + ? `${WEBAPP_BASE_URL}/orgs/${field.value}` + : 'A unique URL to identify your organisation'} + + )} + + + + )} + /> + +
+ + {form.formState.isDirty && ( + + + + )} + + + +
+
+
+ + ); +}; diff --git a/apps/web/src/components/(organisations)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(organisations)/settings/layout/desktop-nav.tsx new file mode 100644 index 000000000..581e7acd8 --- /dev/null +++ b/apps/web/src/components/(organisations)/settings/layout/desktop-nav.tsx @@ -0,0 +1,89 @@ +'use client'; + +import type { HTMLAttributes } from 'react'; + +import Link from 'next/link'; +import { useParams, usePathname } from 'next/navigation'; + +import { Braces, CreditCard, Settings, Users, Webhook } from 'lucide-react'; + +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 orgUrl = typeof params?.orgUrl === 'string' ? params?.orgUrl : ''; + + const settingsPath = `/orgs/${orgUrl}/settings`; + const membersPath = `/orgs/${orgUrl}/settings/members`; + const tokensPath = `/orgs/${orgUrl}/settings/tokens`; + const webhooksPath = `/orgs/${orgUrl}/settings/webhooks`; + const billingPath = `/orgs/${orgUrl}/settings/billing`; + + return ( +
+ + + + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/apps/web/src/components/(organisations)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(organisations)/settings/layout/mobile-nav.tsx new file mode 100644 index 000000000..f701ddd4e --- /dev/null +++ b/apps/web/src/components/(organisations)/settings/layout/mobile-nav.tsx @@ -0,0 +1,100 @@ +'use client'; + +import type { HTMLAttributes } from 'react'; + +import Link from 'next/link'; +import { useParams, usePathname } from 'next/navigation'; + +import { Braces, CreditCard, Key, User, Webhook } 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 orgUrl = typeof params?.orgUrl === 'string' ? params?.orgUrl : ''; + + const settingsPath = `/orgs/${orgUrl}/settings`; + const membersPath = `/orgs/${orgUrl}/settings/members`; + const tokensPath = `/orgs/${orgUrl}/settings/tokens`; + const webhooksPath = `/orgs/${orgUrl}/settings/webhooks`; + const billingPath = `/orgs/${orgUrl}/settings/billing`; + + return ( +
+ + + + + + + + + + + + + + + + + {IS_BILLING_ENABLED() && ( + + + + )} +
+ ); +}; diff --git a/apps/web/src/components/(organisations)/tables/current-user-organisations-data-table.tsx b/apps/web/src/components/(organisations)/tables/current-user-organisations-data-table.tsx new file mode 100644 index 000000000..7a15ad7da --- /dev/null +++ b/apps/web/src/components/(organisations)/tables/current-user-organisations-data-table.tsx @@ -0,0 +1,161 @@ +'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 { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations'; +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 { LeaveOrganisationDialog } from '../dialogs/leave-organisation-dialog'; + +export const CurrentUserOrganisationsDataTable = () => { + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = + trpc.organisation.findOrganisations.useQuery( + { + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + ( + + {row.original.name} + } + secondaryText={`${WEBAPP_BASE_URL}/orgs/${row.original.url}`} + /> + + ), + }, + { + header: 'Role', + accessorKey: 'role', + cell: ({ row }) => + row.original.ownerUserId === row.original.currentMember.userId + ? 'Owner' + : ORGANISATION_MEMBER_ROLE_MAP[row.original.currentMember.role], + }, + { + header: 'Member Since', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + id: 'actions', + cell: ({ row }) => ( +
+ {canExecuteOrganisationAction( + 'MANAGE_ORGANISATION', + row.original.currentMember.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/(organisations)/tables/organisation-member-invites-data-table.tsx b/apps/web/src/components/(organisations)/tables/organisation-member-invites-data-table.tsx new file mode 100644 index 000000000..891c9abc4 --- /dev/null +++ b/apps/web/src/components/(organisations)/tables/organisation-member-invites-data-table.tsx @@ -0,0 +1,205 @@ +'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 { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations'; +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 OrganisationMemberInvitesDataTableProps = { + organisationId: string; +}; + +export const OrganisationMemberInvitesDataTable = ({ + organisationId, +}: OrganisationMemberInvitesDataTableProps) => { + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const { toast } = useToast(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = + trpc.organisation.findOrganisationMemberInvites.useQuery( + { + organisationId, + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const { mutateAsync: resendOrganisationMemberInvitation } = + trpc.organisation.resendOrganisationMemberInvitation.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: deleteOrganisationMemberInvitations } = + trpc.organisation.deleteOrganisationMemberInvitations.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 }) => ORGANISATION_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role, + }, + { + header: 'Invited At', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( + + + + + + + Actions + + + resendOrganisationMemberInvitation({ + organisationId, + invitationId: row.original.id, + }) + } + > + + Resend + + + + deleteOrganisationMemberInvitations({ + organisationId, + 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/(organisations)/tables/organisation-member-page-data-table.tsx b/apps/web/src/components/(organisations)/tables/organisation-member-page-data-table.tsx new file mode 100644 index 000000000..29e35d72b --- /dev/null +++ b/apps/web/src/components/(organisations)/tables/organisation-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 { OrganisationMemberRole } from '@documenso/prisma/client'; +import { Input } from '@documenso/ui/primitives/input'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; + +import { OrganisationMemberInvitesDataTable } from '~/components/(organisations)/tables/organisation-member-invites-data-table'; +import { OrganisationMembersDataTable } from '~/components/(organisations)/tables/organisation-members-data-table'; + +export type OrganisationMemberPageDataTableProps = { + currentUserRole: OrganisationMemberRole; + organisationId: string; + organisationName: string; + organisationOwnerUserId: number; +}; + +export const OrganisationMemberPageDataTable = ({ + currentUserRole, + organisationId, + organisationName, + organisationOwnerUserId, +}: OrganisationMemberPageDataTableProps) => { + 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" + /> + + + + + Active + + + + Pending + + + +
+ + {currentTab === 'invites' ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/apps/web/src/components/(organisations)/tables/organisation-members-data-table.tsx b/apps/web/src/components/(organisations)/tables/organisation-members-data-table.tsx new file mode 100644 index 000000000..24cdbcdef --- /dev/null +++ b/apps/web/src/components/(organisations)/tables/organisation-members-data-table.tsx @@ -0,0 +1,210 @@ +'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 { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; +import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; +import type { OrganisationMemberRole } 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 { DeleteOrganisationMemberDialog } from '../dialogs/delete-organisation-member-dialog'; +import { UpdateOrganisationMemberDialog } from '../dialogs/update-organisation-member-dialog'; + +export type OrganisationMembersDataTableProps = { + currentUserRole: OrganisationMemberRole; + organisationOwnerUserId: number; + organisationId: string; + organisationName: string; +}; + +export const OrganisationMembersDataTable = ({ + currentUserRole, + organisationOwnerUserId, + organisationId, + organisationName, +}: OrganisationMembersDataTableProps) => { + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = + trpc.organisation.findOrganisationMembers.useQuery( + { + organisationId, + query: 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 }) => + organisationOwnerUserId === row.original.userId + ? 'Owner' + : ORGANISATION_MEMBER_ROLE_MAP[row.original.role], + }, + { + header: 'Member Since', + accessorKey: 'createdAt', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( + + + + + + + Actions + + e.preventDefault()} + title="Update organisation member role" + > + + Update role + + } + /> + + e.preventDefault()} + disabled={ + organisationOwnerUserId === row.original.userId || + !isOrganisationRoleWithinUserHierarchy(currentUserRole, row.original.role) + } + title="Remove organisation 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/providers/organisation.tsx b/apps/web/src/providers/organisation.tsx new file mode 100644 index 000000000..093766af3 --- /dev/null +++ b/apps/web/src/providers/organisation.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import React from 'react'; + +import type { Organisation } from '@documenso/prisma/client'; + +interface OrganisationProviderProps { + children: React.ReactNode; + organisation: Organisation; +} + +const OrganisationContext = createContext(null); + +export const useCurrentOrganisation = () => { + const context = useContext(OrganisationContext); + + if (!context) { + throw new Error('useCurrentOrganisation must be used within a OrganisationProvider'); + } + + return context; +}; + +export const useOptionalCurrentOrganisation = () => { + return useContext(OrganisationContext); +}; + +export const OrganisationProvider = ({ children, organisation }: OrganisationProviderProps) => { + return ( + {children} + ); +}; diff --git a/packages/email/templates/organisation-invite.tsx b/packages/email/templates/organisation-invite.tsx new file mode 100644 index 000000000..574a696c5 --- /dev/null +++ b/packages/email/templates/organisation-invite.tsx @@ -0,0 +1,110 @@ +import { formatOrganisationUrl } from '@documenso/lib/utils/organisations'; +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 OrganisationInviteEmailProps = { + assetBaseUrl: string; + baseUrl: string; + senderName: string; + organisationName: string; + organisationUrl: string; + token: string; +}; + +export const OrganisationInviteEmailTemplate = ({ + assetBaseUrl = 'http://localhost:3002', + baseUrl = 'https://documenso.com', + senderName = 'John Doe', + organisationName = 'Organisation Name', + organisationUrl = 'demo', + token = '', +}: OrganisationInviteEmailProps) => { + const previewText = `Accept invitation to join a organisation on Documenso`; + + return ( + + + {previewText} + + +
+ + + +
+ +
+ +
+ + Join {organisationName} on Documenso + + + + You have been invited to join the following organisation + + +
+ {formatOrganisationUrl(organisationUrl, baseUrl)} +
+ + + by {senderName} + + + {/* Todo: Orgs - Display warnings. */} + +
+ +
+
+
+ +
+ + + + +
+ +
+ + ); +}; + +export default OrganisationInviteEmailTemplate; diff --git a/packages/lib/constants/organisations.ts b/packages/lib/constants/organisations.ts new file mode 100644 index 000000000..2409e988c --- /dev/null +++ b/packages/lib/constants/organisations.ts @@ -0,0 +1,30 @@ +import { OrganisationMemberRole } from '@documenso/prisma/client'; + +export const ORGANISATION_URL_ROOT_REGEX = new RegExp('^/orgs/[^/]+$'); +export const ORGANISATION_URL_REGEX = new RegExp('^/orgs/[^/]+'); + +export const ORGANISATION_MEMBER_ROLE_MAP: Record = { + ADMIN: 'Admin', + MANAGER: 'Manager', + MEMBER: 'Member', +}; + +// Todo: Orgs +export const ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP = { + MANAGE_ORGANISATION: [OrganisationMemberRole.ADMIN, OrganisationMemberRole.MANAGER], + MANAGE_BILLING: [OrganisationMemberRole.ADMIN], + DELETE_ORGANISATION_TRANSFER_REQUEST: [OrganisationMemberRole.ADMIN], +} satisfies Record; + +/** + * A hierarchy of member roles to determine which role has higher permission than another. + */ +export const ORGANISATION_MEMBER_ROLE_HIERARCHY = { + [OrganisationMemberRole.ADMIN]: [ + OrganisationMemberRole.ADMIN, + OrganisationMemberRole.MANAGER, + OrganisationMemberRole.MEMBER, + ], + [OrganisationMemberRole.MANAGER]: [OrganisationMemberRole.MANAGER, OrganisationMemberRole.MEMBER], + [OrganisationMemberRole.MEMBER]: [OrganisationMemberRole.MEMBER], +} satisfies Record; diff --git a/packages/lib/constants/teams.ts b/packages/lib/constants/teams.ts index 67f3ef16f..02f4474e5 100644 --- a/packages/lib/constants/teams.ts +++ b/packages/lib/constants/teams.ts @@ -79,6 +79,10 @@ export const PROTECTED_TEAM_URLS = [ 'logout', 'maintenance', 'malware', + 'org', + 'orgs', + 'organisation', + 'organisations', 'newsletter', 'policy', 'privacy', diff --git a/packages/lib/server-only/organisation/accept-organisation-invitation.ts b/packages/lib/server-only/organisation/accept-organisation-invitation.ts new file mode 100644 index 000000000..bdc801079 --- /dev/null +++ b/packages/lib/server-only/organisation/accept-organisation-invitation.ts @@ -0,0 +1,43 @@ +import { prisma } from '@documenso/prisma'; +import { OrganisationMemberStatus } from '@documenso/prisma/client'; + +export type AcceptOrganisationInvitationOptions = { + userId: number; + organisationId: string; +}; + +export const acceptOrganisationInvitation = async ({ + userId, + organisationId, +}: AcceptOrganisationInvitationOptions) => { + await prisma.$transaction(async (tx) => { + const user = await tx.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const organisationMemberInvite = await tx.organisationMemberInvite.findFirstOrThrow({ + where: { + organisationId, + email: user.email, + }, + }); + + await tx.organisationMember.create({ + data: { + name: user.name ?? '', + status: OrganisationMemberStatus.ACTIVE, + organisationId: organisationMemberInvite.organisationId, + userId: user.id, + role: organisationMemberInvite.role, + }, + }); + + await tx.organisationMemberInvite.delete({ + where: { + id: organisationMemberInvite.id, + }, + }); + }); +}; diff --git a/packages/lib/server-only/organisation/create-organisation-member-invites.ts b/packages/lib/server-only/organisation/create-organisation-member-invites.ts new file mode 100644 index 000000000..28e030177 --- /dev/null +++ b/packages/lib/server-only/organisation/create-organisation-member-invites.ts @@ -0,0 +1,166 @@ +import { createElement } from 'react'; + +import { nanoid } from 'nanoid'; + +import { mailer } from '@documenso/email/mailer'; +import { render } from '@documenso/email/render'; +import type { OrganisationInviteEmailProps } from '@documenso/email/templates/organisation-invite'; +import { OrganisationInviteEmailTemplate } from '@documenso/email/templates/organisation-invite'; +import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; +import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; +import { InviteStatus } from '@documenso/prisma/client'; +import type { TCreateOrganisationMemberInvitesMutationSchema } from '@documenso/trpc/server/organisation-router/schema'; + +export type CreateOrganisationMemberInvitesOptions = { + userId: number; + userName: string; + organisationId: string; + invitations: TCreateOrganisationMemberInvitesMutationSchema['invitations']; +}; + +/** + * Invite organisation members via email to join a organisation. + */ +export const createOrganisationMemberInvites = async ({ + userId, + userName, + organisationId, + invitations, +}: CreateOrganisationMemberInvitesOptions) => { + const organisation = await prisma.organisation.findFirstOrThrow({ + where: { + id: organisationId, + members: { + some: { + userId, + role: { + in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }, + }, + }, + }, + include: { + members: { + select: { + role: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + invites: true, + }, + }); + + const organisationMemberEmails = organisation.members.map((member) => member.user.email); + const organisationMemberInviteEmails = organisation.invites.map((invite) => invite.email); + const currentOrganisationMember = organisation.members.find( + (member) => member.user.id === userId, + ); + + if (!currentOrganisationMember) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'User not part of organisation.'); + } + + const usersToInvite = invitations.filter((invitation) => { + // Filter out users that are already members of the organisation. + if (organisationMemberEmails.includes(invitation.email)) { + return false; + } + + // Filter out users that have already been invited to the organisation. + if (organisationMemberInviteEmails.includes(invitation.email)) { + return false; + } + + return true; + }); + + const unauthorizedRoleAccess = usersToInvite.some( + ({ role }) => !isOrganisationRoleWithinUserHierarchy(currentOrganisationMember.role, role), + ); + + if (unauthorizedRoleAccess) { + throw new AppError( + AppErrorCode.UNAUTHORIZED, + 'User does not have permission to set high level roles', + ); + } + + const organisationMemberInvites = usersToInvite.map(({ email, role }) => ({ + email, + organisationId, + role, + status: InviteStatus.PENDING, + token: nanoid(32), + })); + + await prisma.organisationMemberInvite.createMany({ + data: organisationMemberInvites, + }); + + const sendEmailResult = await Promise.allSettled( + organisationMemberInvites.map(async ({ email, token }) => + sendOrganisationMemberInviteEmail({ + email, + token, + organisationName: organisation.name, + organisationUrl: organisation.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}/${organisationMemberInvites.length} users.`, + ); + } +}; + +type SendOrganisationMemberInviteEmailOptions = Omit< + OrganisationInviteEmailProps, + 'baseUrl' | 'assetBaseUrl' +> & { + email: string; +}; + +/** + * Send an email to a user inviting them to join a organisation. + */ +export const sendOrganisationMemberInviteEmail = async ({ + email, + ...emailTemplateOptions +}: SendOrganisationMemberInviteEmailOptions) => { + const template = createElement(OrganisationInviteEmailTemplate, { + 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.organisationName} on Documenso`, + html: render(template), + text: render(template, { plainText: true }), + }); +}; diff --git a/packages/lib/server-only/organisation/create-organisation.ts b/packages/lib/server-only/organisation/create-organisation.ts new file mode 100644 index 000000000..374ae6965 --- /dev/null +++ b/packages/lib/server-only/organisation/create-organisation.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; + +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; +import { OrganisationMemberRole, OrganisationMemberStatus, Prisma } from '@documenso/prisma/client'; + +export type CreateOrganisationOptions = { + /** + * ID of the user creating the Team. + */ + userId: number; + + /** + * Name of the organisation to display. + */ + organisationName: string; + + /** + * Unique URL of the organisation. + * + * Used as the URL path, example: https://documenso.com/orgs/{orgUrl}/settings + */ + organisationUrl: string; +}; + +/** + * Create an organisation. + */ +export const createOrganisation = async ({ + userId, + organisationName, + organisationUrl, +}: CreateOrganisationOptions): Promise => { + const user = await prisma.user.findUniqueOrThrow({ + where: { + id: userId, + }, + include: { + Subscription: true, + }, + }); + + // Todo: Orgs - max 1 org per enterprise user & billing must be enabled, active, etc + if (!IS_BILLING_ENABLED()) { + throw new AppError('TODO'); + } + + try { + await prisma.organisation.create({ + data: { + name: organisationName, + url: organisationUrl, + ownerUserId: user.id, + members: { + create: [ + { + name: user.name ?? '', + userId, + status: OrganisationMemberStatus.ACTIVE, + role: OrganisationMemberRole.ADMIN, + }, + ], + }, + }, + }); + } 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, 'Organisation URL already exists.'); + } + + throw err; + } +}; diff --git a/packages/lib/server-only/organisation/delete-organisation-member-invitations.ts b/packages/lib/server-only/organisation/delete-organisation-member-invitations.ts new file mode 100644 index 000000000..c8a844ae1 --- /dev/null +++ b/packages/lib/server-only/organisation/delete-organisation-member-invitations.ts @@ -0,0 +1,39 @@ +import { prisma } from '@documenso/prisma'; + +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations'; + +export type DeleteTeamMemberInvitationsOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + organisationId: string; + invitationIds: string[]; +}; + +export const deleteOrganisationMemberInvitations = async ({ + userId, + organisationId, + invitationIds, +}: DeleteTeamMemberInvitationsOptions) => { + await prisma.$transaction(async (tx) => { + await tx.organisationMember.findFirstOrThrow({ + where: { + userId, + organisationId, + role: { + in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }, + }, + }); + + await tx.organisationMemberInvite.deleteMany({ + where: { + id: { + in: invitationIds, + }, + organisationId, + }, + }); + }); +}; diff --git a/packages/lib/server-only/organisation/delete-organisation-members.ts b/packages/lib/server-only/organisation/delete-organisation-members.ts new file mode 100644 index 000000000..fb7590335 --- /dev/null +++ b/packages/lib/server-only/organisation/delete-organisation-members.ts @@ -0,0 +1,86 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; + +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations'; + +export type DeleteOrganisationMembersOptions = { + /** + * The ID of the user who is initiating this action. + */ + userId: number; + + /** + * The ID of the organisation to remove members from. + */ + organisationId: string; + + /** + * The IDs of the members to remove. + */ + memberIds: string[]; +}; + +export const deleteOrganisationMembers = async ({ + userId, + organisationId, + memberIds, +}: DeleteOrganisationMembersOptions) => { + await prisma.$transaction(async (tx) => { + // Find the organisation and validate that the user is allowed to remove members. + const organisation = await tx.organisation.findFirstOrThrow({ + where: { + id: organisationId, + members: { + some: { + userId, + role: { + in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }, + }, + }, + }, + include: { + members: { + select: { + id: true, + userId: true, + role: true, + }, + }, + }, + }); + + const currentMember = organisation.members.find((member) => member.userId === userId); + const membersToRemove = organisation.members.filter((member) => memberIds.includes(member.id)); + + if (!currentMember) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Organisation member record does not exist'); + } + + if (membersToRemove.find((member) => member.userId === organisation.ownerUserId)) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove the organisation owner'); + } + + const isMemberToRemoveHigherRole = membersToRemove.some( + (member) => !isOrganisationRoleWithinUserHierarchy(currentMember.role, member.role), + ); + + if (isMemberToRemoveHigherRole) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot remove a member with a higher role'); + } + + // Remove the members. + await tx.organisationMember.deleteMany({ + where: { + id: { + in: memberIds, + }, + organisationId, + userId: { + not: organisation.ownerUserId, + }, + }, + }); + }); +}; diff --git a/packages/lib/server-only/organisation/find-organisation-member-invites.ts b/packages/lib/server-only/organisation/find-organisation-member-invites.ts new file mode 100644 index 000000000..e011afb06 --- /dev/null +++ b/packages/lib/server-only/organisation/find-organisation-member-invites.ts @@ -0,0 +1,91 @@ +import { P, match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { OrganisationMemberInvite } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations'; +import type { FindResultSet } from '../../types/find-result-set'; + +export interface FindOrganisationMemberInvitesOptions { + userId: number; + organisationId: string; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof OrganisationMemberInvite; + direction: 'asc' | 'desc'; + }; +} + +export const findOrganisationMemberInvites = async ({ + userId, + organisationId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindOrganisationMemberInvitesOptions) => { + const orderByColumn = orderBy?.column ?? 'email'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + // Check that the user belongs to the organisation they are trying to find invites in. + const userOrganisation = await prisma.organisation.findUniqueOrThrow({ + where: { + id: organisationId, + members: { + some: { + userId, + role: { + in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }, + }, + }, + }, + }); + + const termFilters: Prisma.OrganisationMemberInviteWhereInput | undefined = match(term) + .with(P.string.minLength(1), () => ({ + email: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + })) + .otherwise(() => undefined); + + const whereClause: Prisma.OrganisationMemberInviteWhereInput = { + ...termFilters, + organisationId: userOrganisation.id, + }; + + const [data, count] = await Promise.all([ + prisma.organisationMemberInvite.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + // Exclude token attribute. + select: { + id: true, + organisationId: true, + email: true, + role: true, + createdAt: true, + }, + }), + prisma.organisationMemberInvite.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/organisation/find-organisation-members.ts b/packages/lib/server-only/organisation/find-organisation-members.ts new file mode 100644 index 000000000..c02349f1e --- /dev/null +++ b/packages/lib/server-only/organisation/find-organisation-members.ts @@ -0,0 +1,102 @@ +import { P, match } from 'ts-pattern'; + +import { prisma } from '@documenso/prisma'; +import type { OrganisationMember } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +import type { FindResultSet } from '../../types/find-result-set'; + +export interface FindOrganisationMembersOptions { + userId: number; + organisationId: string; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof OrganisationMember | 'name'; + direction: 'asc' | 'desc'; + }; +} + +export const findOrganisationMembers = async ({ + userId, + organisationId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindOrganisationMembersOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + // Check that the user belongs to the organisation they are trying to find members in. + const userOrganisation = await prisma.organisation.findUniqueOrThrow({ + where: { + id: organisationId, + members: { + some: { + userId, + }, + }, + }, + }); + + console.log(term); + + const termFilters: Prisma.OrganisationMemberWhereInput | undefined = match(term) + .with(P.string.minLength(1), () => ({ + user: { + name: { + contains: term, + mode: Prisma.QueryMode.insensitive, + }, + }, + })) + .otherwise(() => undefined); + + const whereClause: Prisma.OrganisationMemberWhereInput = { + ...termFilters, + organisationId: userOrganisation.id, + }; + + let orderByClause: Prisma.OrganisationMemberOrderByWithRelationInput = { + [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.organisationMember.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: orderByClause, + include: { + user: { + select: { + name: true, + email: true, + }, + }, + }, + }), + prisma.organisationMember.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/organisation/find-organisations.ts b/packages/lib/server-only/organisation/find-organisations.ts new file mode 100644 index 000000000..e42b02948 --- /dev/null +++ b/packages/lib/server-only/organisation/find-organisations.ts @@ -0,0 +1,76 @@ +import type { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { prisma } from '@documenso/prisma'; +import type { Organisation } from '@documenso/prisma/client'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindOrganisationsOptions { + userId: number; + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Organisation; + direction: 'asc' | 'desc'; + }; +} + +export const findOrganisations = async ({ + userId, + term, + page = 1, + perPage = 10, + orderBy, +}: FindOrganisationsOptions) => { + const orderByColumn = orderBy?.column ?? 'name'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const whereClause: Prisma.OrganisationWhereInput = { + members: { + some: { + userId, + }, + }, + }; + + if (term && term.length > 0) { + whereClause.name = { + contains: term, + mode: Prisma.QueryMode.insensitive, + }; + } + + const [data, count] = await Promise.all([ + prisma.organisation.findMany({ + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + members: { + where: { + userId, + }, + }, + }, + }), + prisma.organisation.count({ + where: whereClause, + }), + ]); + + const maskedData = data.map((organisation) => ({ + ...organisation, + currentMember: organisation.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/organisation/get-organisation.ts b/packages/lib/server-only/organisation/get-organisation.ts new file mode 100644 index 000000000..177dac73c --- /dev/null +++ b/packages/lib/server-only/organisation/get-organisation.ts @@ -0,0 +1,96 @@ +import { prisma } from '@documenso/prisma'; +import type { Prisma } from '@documenso/prisma/client'; + +export type GetOrganisationByIdOptions = { + userId?: number; + organisationId: string; +}; + +/** + * Get an organisation given an organisationId. + * + * Provide an optional userId to check that the user is a member of the organisation. + */ +export const getOrganisationById = async ({ + userId, + organisationId, +}: GetOrganisationByIdOptions) => { + const whereFilter: Prisma.OrganisationWhereUniqueInput = { + id: organisationId, + }; + + if (userId !== undefined) { + whereFilter['members'] = { + some: { + userId, + }, + }; + } + + const result = await prisma.organisation.findUniqueOrThrow({ + where: whereFilter, + include: { + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + const { members, ...organisation } = result; + + return { + ...organisation, + currentMember: userId !== undefined ? members[0] : null, + }; +}; + +export type GetOrganisationByUrlOptions = { + userId: number; + organisationUrl: string; +}; + +/** + * Get an organisation given an organisation URL. + */ +export const getOrganisationByUrl = async ({ + userId, + organisationUrl, +}: GetOrganisationByUrlOptions) => { + const whereFilter: Prisma.OrganisationWhereUniqueInput = { + url: organisationUrl, + }; + + if (userId !== undefined) { + whereFilter['members'] = { + some: { + userId, + }, + }; + } + + const result = await prisma.organisation.findUniqueOrThrow({ + where: whereFilter, + include: { + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + const { members, ...organisation } = result; + + return { + ...organisation, + currentMember: members[0], + }; +}; diff --git a/packages/lib/server-only/organisation/get-organisations.ts b/packages/lib/server-only/organisation/get-organisations.ts new file mode 100644 index 000000000..189de1466 --- /dev/null +++ b/packages/lib/server-only/organisation/get-organisations.ts @@ -0,0 +1,33 @@ +import { prisma } from '@documenso/prisma'; + +export type GetOrganisationsOptions = { + userId: number; +}; +export type GetOrganisationsResponse = Awaited>; + +export const getOrganisations = async ({ userId }: GetOrganisationsOptions) => { + const organisations = await prisma.organisation.findMany({ + where: { + members: { + some: { + userId, + }, + }, + }, + include: { + members: { + where: { + userId, + }, + select: { + role: true, + }, + }, + }, + }); + + return organisations.map(({ members, ...organisation }) => ({ + ...organisation, + currentMember: members[0], + })); +}; diff --git a/packages/lib/server-only/organisation/leave-organisation.ts b/packages/lib/server-only/organisation/leave-organisation.ts new file mode 100644 index 000000000..e4b860a2a --- /dev/null +++ b/packages/lib/server-only/organisation/leave-organisation.ts @@ -0,0 +1,55 @@ +import { prisma } from '@documenso/prisma'; + +import { AppError } from '../../errors/app-error'; + +export type LeaveOrganisationOptions = { + /** + * The ID of the user who is leaving the organisation. + */ + userId: number; + + /** + * The ID of the organisation the user is leaving. + */ + organisationId: string; +}; + +export const leaveOrganisation = async ({ userId, organisationId }: LeaveOrganisationOptions) => { + const organisation = await prisma.organisation.findFirstOrThrow({ + where: { + id: organisationId, + ownerUserId: { + not: userId, + }, + }, + include: { + teams: { + where: { + ownerUserId: userId, + }, + }, + }, + }); + + // Todo: Orgs - Test this. + if (organisation.teams.length > 0) { + throw new AppError( + 'USER_HAS_TEAMS', + 'You cannot leave an organisation if you are the owner of a team in it.', + ); + } + + await prisma.organisationMember.delete({ + where: { + userId_organisationId: { + userId, + organisationId, + }, + organisation: { + ownerUserId: { + not: userId, + }, + }, + }, + }); +}; diff --git a/packages/lib/server-only/organisation/resend-organisation-member-invitation.ts b/packages/lib/server-only/organisation/resend-organisation-member-invitation.ts new file mode 100644 index 000000000..a791d0dd8 --- /dev/null +++ b/packages/lib/server-only/organisation/resend-organisation-member-invitation.ts @@ -0,0 +1,82 @@ +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; + +import { sendOrganisationMemberInviteEmail } from './create-organisation-member-invites'; + +export type ResendOrganisationMemberInvitationOptions = { + /** + * 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 organisation. + */ + organisationId: string; + + /** + * The IDs of the invitations to resend. + */ + invitationId: string; +}; + +/** + * Resend an email for a given organisation member invite. + */ +export const resendOrganisationMemberInvitation = async ({ + userId, + userName, + organisationId, + invitationId, +}: ResendOrganisationMemberInvitationOptions) => { + await prisma.$transaction( + async (tx) => { + const organisation = await tx.organisation.findUniqueOrThrow({ + where: { + id: organisationId, + members: { + some: { + userId, + role: { + in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }, + }, + }, + }, + }); + + if (!organisation) { + throw new AppError( + 'OrganisationNotFound', + 'User is not a valid member of the organisation.', + ); + } + + const organisationMemberInvite = await tx.organisationMemberInvite.findUniqueOrThrow({ + where: { + id: invitationId, + organisationId, + }, + }); + + if (!organisationMemberInvite) { + throw new AppError('InviteNotFound', 'No invite exists for this user.'); + } + + await sendOrganisationMemberInviteEmail({ + email: organisationMemberInvite.email, + token: organisationMemberInvite.token, + organisationName: organisation.name, + organisationUrl: organisation.url, + senderName: userName, + }); + }, + { timeout: 30_000 }, + ); +}; diff --git a/packages/lib/server-only/organisation/update-organisation-member.ts b/packages/lib/server-only/organisation/update-organisation-member.ts new file mode 100644 index 000000000..1748ea084 --- /dev/null +++ b/packages/lib/server-only/organisation/update-organisation-member.ts @@ -0,0 +1,96 @@ +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations'; +import { prisma } from '@documenso/prisma'; +import type { OrganisationMemberRole } from '@documenso/prisma/client'; + +export type UpdateOrganisationMemberOptions = { + userId: number; + organisationId: string; + organisationMemberId: string; + data: { + role: OrganisationMemberRole; + }; +}; + +export const updateOrganisationMember = async ({ + userId, + organisationId, + organisationMemberId, + data, +}: UpdateOrganisationMemberOptions) => { + await prisma.$transaction(async (tx) => { + // Find the organisation and validate that the user is allowed to update members. + const organisation = await tx.organisation.findFirstOrThrow({ + where: { + id: organisationId, + members: { + some: { + userId, + role: { + in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }, + }, + }, + }, + include: { + members: { + select: { + id: true, + userId: true, + role: true, + }, + }, + }, + }); + + const currentOrganisationMember = organisation.members.find( + (member) => member.userId === userId, + ); + const organisationMemberToUpdate = organisation.members.find( + (member) => member.id === organisationMemberId, + ); + + if (!organisationMemberToUpdate || !currentOrganisationMember) { + throw new AppError(AppErrorCode.NOT_FOUND, 'Organisation member does not exist'); + } + + if (organisationMemberToUpdate.userId === organisation.ownerUserId) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update the owner'); + } + + const isMemberToUpdateHigherRole = !isOrganisationRoleWithinUserHierarchy( + currentOrganisationMember.role, + organisationMemberToUpdate.role, + ); + + if (isMemberToUpdateHigherRole) { + throw new AppError(AppErrorCode.UNAUTHORIZED, 'Cannot update a member with a higher role'); + } + + const isNewMemberRoleHigherThanCurrentRole = !isOrganisationRoleWithinUserHierarchy( + currentOrganisationMember.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.organisationMember.update({ + where: { + id: organisationMemberId, + organisationId, + userId: { + not: organisation.ownerUserId, + }, + }, + data: { + role: data.role, + }, + }); + }); +}; diff --git a/packages/lib/server-only/organisation/update-organisation.ts b/packages/lib/server-only/organisation/update-organisation.ts new file mode 100644 index 000000000..b53cd0ad5 --- /dev/null +++ b/packages/lib/server-only/organisation/update-organisation.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations'; + +export type UpdateOrganisationOptions = { + userId: number; + organisationId: string; + data: { + name?: string; + url?: string; + }; +}; + +export const updateOrganisation = async ({ + userId, + organisationId, + data, +}: UpdateOrganisationOptions) => { + try { + return await prisma.organisation.update({ + where: { + id: organisationId, + members: { + some: { + userId, + role: { + in: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'], + }, + }, + }, + }, + data: { + url: data.url, + name: data.name, + }, + }); + } 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, 'Organisation 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 263fa9392..410f79d1f 100644 --- a/packages/lib/server-only/user/create-user.ts +++ b/packages/lib/server-only/user/create-user.ts @@ -3,7 +3,13 @@ import { hash } from '@node-rs/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, Prisma, TeamMemberInviteStatus } from '@documenso/prisma/client'; +import { + IdentityProvider, + InviteStatus, + OrganisationMemberStatus, + Prisma, + TeamMemberInviteStatus, +} from '@documenso/prisma/client'; import { IS_BILLING_ENABLED } from '../../constants/app'; import { SALT_ROUNDS } from '../../constants/auth'; @@ -57,76 +63,119 @@ export const createUser = async ({ name, email, password, signature, url }: Crea }, }); - const acceptedTeamInvites = await prisma.teamMemberInvite.findMany({ - where: { - email: { - equals: email, - mode: Prisma.QueryMode.insensitive, + const [acceptedTeamInvites, acceptedOrganisationInvites] = await Promise.all([ + prisma.teamMemberInvite.findMany({ + where: { + email: { + equals: email, + mode: Prisma.QueryMode.insensitive, + }, + status: TeamMemberInviteStatus.ACCEPTED, }, - status: TeamMemberInviteStatus.ACCEPTED, - }, - }); + }), + prisma.organisationMemberInvite.findMany({ + where: { + email: { + equals: email, + mode: Prisma.QueryMode.insensitive, + }, + status: InviteStatus.ACCEPTED, + }, + }), + ]); - // For each team invite, add the user to the team and delete the team invite. + // For each org/team invite, add the user to the org/team and delete the 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({ + [ + 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, + }); + } + }, + { timeout: 30_000 }, + ) + .catch(async () => { + await prisma.teamMemberInvite.update({ + where: { + id: invite.id, + }, data: { - teamId: invite.teamId, + status: TeamMemberInviteStatus.PENDING, + }, + }); + }), + ), + acceptedOrganisationInvites.map(async (invite) => + prisma + .$transaction(async (tx) => { + await tx.organisationMember.create({ + data: { + name: user.name ?? '', + status: OrganisationMemberStatus.ACTIVE, + organisationId: invite.organisationId, userId: user.id, role: invite.role, }, }); - await tx.teamMemberInvite.delete({ + await tx.organisationMemberInvite.delete({ where: { id: invite.id, }, }); - - if (!IS_BILLING_ENABLED()) { - return; - } - - const team = await tx.team.findFirstOrThrow({ + }) + .catch(async () => { + await prisma.organisationMemberInvite.update({ where: { - id: invite.teamId, + id: invite.id, }, - include: { - members: { - select: { - id: true, - }, - }, - subscription: true, + data: { + status: InviteStatus.PENDING, }, }); - - if (team.subscription) { - await updateSubscriptionItemQuantity({ - priceId: team.subscription.priceId, - subscriptionId: team.subscription.planId, - quantity: team.members.length, - }); - } - }, - { timeout: 30_000 }, - ) - .catch(async () => { - await prisma.teamMemberInvite.update({ - where: { - id: invite.id, - }, - data: { - status: TeamMemberInviteStatus.PENDING, - }, - }); - }), - ), + }), + ), + ].flat(), ); // Update the user record with a new or existing Stripe customer record. diff --git a/packages/lib/utils/organisations.ts b/packages/lib/utils/organisations.ts new file mode 100644 index 000000000..430b16fe8 --- /dev/null +++ b/packages/lib/utils/organisations.ts @@ -0,0 +1,66 @@ +import { WEBAPP_BASE_URL } from '../constants/app'; +import type { ORGANISATION_MEMBER_ROLE_MAP } from '../constants/organisations'; +import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../constants/organisations'; +import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '../constants/organisations'; + +export const formatOrganisationUrl = (orgUrl: string, baseUrl?: string) => { + const formattedBaseUrl = (baseUrl ?? WEBAPP_BASE_URL).replace(/https?:\/\//, ''); + + return `${formattedBaseUrl}/orgs/${orgUrl}`; +}; + +// Todo: Maybe share with teams? +export const formatDocumentsPathProto = ({ + orgUrl, + teamUrl, +}: { + orgUrl?: string; + teamUrl?: string; +}) => { + if (!orgUrl && !teamUrl) { + throw new Error('Todo?'); + } + + return teamUrl ? `/orgs/${orgUrl}/t/${teamUrl}/documents` : `/orgs/${orgUrl}/documents`; +}; + +// Todo: Maybe share with teams? +export const formatDocumentsPath = (orgUrl: string, teamUrl?: string) => { + return teamUrl ? `/orgs/${orgUrl}/t/${teamUrl}/documents` : `/orgs/${orgUrl}/documents`; +}; + +// Todo: Orgs - Common templates between teams? +export const formatTemplatesPath = (orgUrl: string, teamUrl?: string) => { + return `/orgs/${orgUrl}/t/${teamUrl}/templates`; +}; + +/** + * Determines whether an organisation 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 canExecuteOrganisationAction = ( + action: keyof typeof ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP, + role: keyof typeof ORGANISATION_MEMBER_ROLE_MAP, +) => { + return ORGANISATION_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 + * + * Todo: Orgs + */ +export const isOrganisationRoleWithinUserHierarchy = ( + currentUserRole: keyof typeof ORGANISATION_MEMBER_ROLE_MAP, + roleToCheck: keyof typeof ORGANISATION_MEMBER_ROLE_MAP, +) => { + return ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserRole].some((i) => i === roleToCheck); +}; diff --git a/packages/trpc/server/organisation-router/router.ts b/packages/trpc/server/organisation-router/router.ts new file mode 100644 index 000000000..9d42ae7fc --- /dev/null +++ b/packages/trpc/server/organisation-router/router.ts @@ -0,0 +1,308 @@ +import { AppError } from '@documenso/lib/errors/app-error'; +import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation'; +import { createOrganisationMemberInvites } from '@documenso/lib/server-only/organisation/create-organisation-member-invites'; +import { deleteOrganisationMemberInvitations } from '@documenso/lib/server-only/organisation/delete-organisation-member-invitations'; +import { deleteOrganisationMembers } from '@documenso/lib/server-only/organisation/delete-organisation-members'; +import { findOrganisationMemberInvites } from '@documenso/lib/server-only/organisation/find-organisation-member-invites'; +import { findOrganisationMembers } from '@documenso/lib/server-only/organisation/find-organisation-members'; +import { findOrganisations } from '@documenso/lib/server-only/organisation/find-organisations'; +import { leaveOrganisation } from '@documenso/lib/server-only/organisation/leave-organisation'; +import { resendOrganisationMemberInvitation } from '@documenso/lib/server-only/organisation/resend-organisation-member-invitation'; +import { updateOrganisation } from '@documenso/lib/server-only/organisation/update-organisation'; +import { updateOrganisationMember } from '@documenso/lib/server-only/organisation/update-organisation-member'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZCreateOrganisationMemberInvitesMutationSchema, + ZCreateOrganisationMutationSchema, + ZDeleteOrganisationMemberInvitationsMutationSchema, + ZDeleteOrganisationMembersMutationSchema, + ZFindOrganisationMemberInvitesQuerySchema, + ZFindOrganisationMembersQuerySchema, + ZFindOrganisationsQuerySchema, + ZLeaveOrganisationMutationSchema, + ZResendOrganisationMemberInvitationMutationSchema, + ZUpdateOrganisationMemberMutationSchema, + ZUpdateOrganisationMutationSchema, +} from './schema'; + +export const organisationRouter = 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); + // } + // }), + + createOrganisation: authenticatedProcedure + .input(ZCreateOrganisationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createOrganisation({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + // Todo: Alert + + throw AppError.parseErrorToTRPCError(err); + } + }), + + createOrganisationMemberInvites: authenticatedProcedure + .input(ZCreateOrganisationMemberInvitesMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await createOrganisationMemberInvites({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteOrganisationMemberInvitations: authenticatedProcedure + .input(ZDeleteOrganisationMemberInvitationsMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteOrganisationMemberInvitations({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + deleteOrganisationMembers: authenticatedProcedure + .input(ZDeleteOrganisationMembersMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await deleteOrganisationMembers({ + 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); + // } + // }), + + findOrganisationMemberInvites: authenticatedProcedure + .input(ZFindOrganisationMemberInvitesQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findOrganisationMemberInvites({ + userId: ctx.user.id, + term: input.query, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findOrganisationMembers: authenticatedProcedure + .input(ZFindOrganisationMembersQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findOrganisationMembers({ + userId: ctx.user.id, + term: input.query, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + findOrganisations: authenticatedProcedure + .input(ZFindOrganisationsQuerySchema) + .query(async ({ input, ctx }) => { + try { + return await findOrganisations({ + userId: ctx.user.id, + term: input.query, + ...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); + // } + // }), + + leaveOrganisation: authenticatedProcedure + .input(ZLeaveOrganisationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await leaveOrganisation({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateOrganisation: authenticatedProcedure + .input(ZUpdateOrganisationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateOrganisation({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + updateOrganisationMember: authenticatedProcedure + .input(ZUpdateOrganisationMemberMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + return await updateOrganisationMember({ + userId: ctx.user.id, + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), + + resendOrganisationMemberInvitation: authenticatedProcedure + .input(ZResendOrganisationMemberInvitationMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + await resendOrganisationMemberInvitation({ + userId: ctx.user.id, + userName: ctx.user.name ?? '', + ...input, + }); + } catch (err) { + console.error(err); + + throw AppError.parseErrorToTRPCError(err); + } + }), +}); diff --git a/packages/trpc/server/organisation-router/schema.ts b/packages/trpc/server/organisation-router/schema.ts new file mode 100644 index 000000000..e2e5f21a8 --- /dev/null +++ b/packages/trpc/server/organisation-router/schema.ts @@ -0,0 +1,171 @@ +import { z } from 'zod'; + +import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { OrganisationMemberRole } from '@documenso/prisma/client'; + +/** + * 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 + */ +// Todo: Orgs - Resuse from teams +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({ + organisationId: z.string(), +}); + +export const ZCreateTeamBillingPortalMutationSchema = z.object({ + organisationId: z.string(), +}); + +export const ZCreateOrganisationMutationSchema = z.object({ + organisationName: ZTeamNameSchema, + organisationUrl: ZTeamUrlSchema, +}); + +export const ZCreateTeamEmailVerificationMutationSchema = z.object({ + organisationId: z.string(), + 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 ZCreateOrganisationMemberInvitesMutationSchema = z.object({ + organisationId: z.string(), + invitations: z.array( + z.object({ + email: z.string().email().toLowerCase(), + role: z.nativeEnum(OrganisationMemberRole), + }), + ), +}); + +export const ZCreateTeamPendingCheckoutMutationSchema = z.object({ + interval: z.union([z.literal('monthly'), z.literal('yearly')]), + pendingTeamId: z.number(), +}); + +export const ZDeleteTeamEmailMutationSchema = z.object({ + organisationId: z.string(), +}); + +export const ZDeleteTeamEmailVerificationMutationSchema = z.object({ + organisationId: z.string(), +}); + +export const ZDeleteOrganisationMembersMutationSchema = z.object({ + organisationId: z.string(), + memberIds: z.array(z.string()), +}); + +export const ZDeleteOrganisationMemberInvitationsMutationSchema = z.object({ + organisationId: z.string(), + invitationIds: z.array(z.string()), +}); + +export const ZDeleteTeamMutationSchema = z.object({ + organisationId: z.string(), +}); + +export const ZDeleteTeamPendingMutationSchema = z.object({ + pendingTeamId: z.number(), +}); + +export const ZDeleteTeamTransferRequestMutationSchema = z.object({ + organisationId: z.string(), +}); + +export const ZFindTeamInvoicesQuerySchema = z.object({ + organisationId: z.string(), +}); + +export const ZFindOrganisationMemberInvitesQuerySchema = ZBaseTableSearchParamsSchema.extend({ + organisationId: z.string(), +}); + +export const ZFindOrganisationMembersQuerySchema = ZBaseTableSearchParamsSchema.extend({ + organisationId: z.string(), +}); + +export const ZFindOrganisationsQuerySchema = ZBaseTableSearchParamsSchema; + +export const ZGetTeamQuerySchema = z.object({ + organisationId: z.string(), +}); + +export const ZGetTeamMembersQuerySchema = z.object({ + organisationId: z.string(), +}); + +export const ZLeaveOrganisationMutationSchema = z.object({ + organisationId: z.string(), +}); + +export const ZUpdateOrganisationMutationSchema = z.object({ + organisationId: z.string(), + data: z.object({ + name: ZTeamNameSchema, // Todo: Orgs + url: ZTeamUrlSchema, + }), +}); + +export const ZUpdateTeamEmailMutationSchema = z.object({ + organisationId: z.string(), + data: z.object({ + name: z.string().trim().min(1), + }), +}); + +export const ZUpdateOrganisationMemberMutationSchema = z.object({ + organisationId: z.string(), + organisationMemberId: z.string(), + data: z.object({ + role: z.nativeEnum(OrganisationMemberRole), + }), +}); + +export const ZRequestTeamOwnerhsipTransferMutationSchema = z.object({ + organisationId: z.string(), + newOwnerUserId: z.number(), + clearPaymentMethods: z.boolean(), +}); + +export const ZResendOrganisationMemberInvitationMutationSchema = z.object({ + organisationId: z.string(), + invitationId: z.string(), +}); + +export type TCreateOrganisationMemberInvitesMutationSchema = z.infer< + typeof ZCreateOrganisationMemberInvitesMutationSchema +>; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 16f79f712..de0d923fd 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -4,6 +4,7 @@ import { authRouter } from './auth-router/router'; import { cryptoRouter } from './crypto/router'; import { documentRouter } from './document-router/router'; import { fieldRouter } from './field-router/router'; +import { organisationRouter } from './organisation-router/router'; import { profileRouter } from './profile-router/router'; import { recipientRouter } from './recipient-router/router'; import { shareLinkRouter } from './share-link-router/router'; @@ -25,6 +26,7 @@ export const appRouter = router({ shareLink: shareLinkRouter, apiToken: apiTokenRouter, singleplayer: singleplayerRouter, + organisation: organisationRouter, team: teamRouter, template: templateRouter, webhook: webhookRouter,