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
+
+
+
+ );
+}
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.
+
+
+
+ Return
+
+
+
+ );
+ }
+
+ 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.
+
+
+
+ Create account
+
+
+ );
+ }
+
+ const isSessionUserTheInvitedUser = user.id === session.user?.id;
+
+ return (
+
+
Invitation accepted!
+
+
+ You have accepted an invitation from {organisation.name} to join their
+ organisation.
+
+
+ {isSessionUserTheInvitedUser ? (
+
+ Continue
+
+ ) : (
+
+ Continue to login
+
+ )}
+
+ );
+}
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) => {
+
+
+
+ Organisations
+
+
+
{
+
+
+
+ Organisations
+
+
+
;
+
+const ZCreateOrganisationFormSchema = ZCreateOrganisationMutationSchema.pick({
+ organisationName: true,
+ organisationUrl: true,
+});
+
+type TCreateOrganisationFormSchema = z.infer;
+
+export const CreateOrganisationDialog = ({ trigger, ...props }: CreateOrganisationDialogProps) => {
+ const { toast } = useToast();
+
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const updateSearchParams = useUpdateSearchParams();
+
+ const [open, setOpen] = useState(false);
+
+ const actionSearchParam = searchParams?.get('action');
+
+ const form = useForm({
+ resolver: zodResolver(ZCreateOrganisationFormSchema),
+ defaultValues: {
+ organisationName: '',
+ organisationUrl: '',
+ },
+ });
+
+ const { mutateAsync: createOrganisation } = trpc.organisation.createOrganisation.useMutation();
+
+ const onFormSubmit = async ({
+ organisationName,
+ organisationUrl,
+ }: TCreateOrganisationFormSchema) => {
+ try {
+ await createOrganisation({
+ organisationName,
+ organisationUrl,
+ });
+
+ setOpen(false);
+
+ toast({
+ title: 'Success',
+ description: 'Your organisation has been created.',
+ duration: 5000,
+ });
+ } catch (err) {
+ const error = AppError.parseError(err);
+
+ if (error.code === AppErrorCode.ALREADY_EXISTS) {
+ form.setError('organisationUrl', {
+ type: 'manual',
+ message: 'This URL is already in use.',
+ });
+
+ return;
+ }
+
+ toast({
+ title: 'An unknown error occurred',
+ variant: 'destructive',
+ description:
+ 'We encountered an unknown error while attempting to create an organisation. Please try again later.',
+ });
+ }
+ };
+
+ const mapTextToUrl = (text: string) => {
+ return text.toLowerCase().replace(/\s+/g, '-');
+ };
+
+ useEffect(() => {
+ if (actionSearchParam === 'create-organisation') {
+ setOpen(true);
+ updateSearchParams({ action: null });
+ }
+ }, [actionSearchParam, open, setOpen, updateSearchParams]);
+
+ useEffect(() => {
+ form.reset();
+ }, [open, form]);
+
+ return (
+ !form.formState.isSubmitting && setOpen(value)}
+ >
+ e.stopPropagation()} asChild={true}>
+ {trigger ?? (
+
+ Create organisation
+
+ )}
+
+
+
+
+ Create organisation
+
+
+ Create an organisation to collaborate with teams.
+
+
+
+
+
+
+
+ );
+};
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 ?? Delete organisation member }
+
+
+
+
+ Are you sure?
+
+
+ You are about to remove the following user from{' '}
+ {organisationName} .
+
+
+
+
+ {organisationMemberName}}
+ secondaryText={organisationMemberEmail}
+ />
+
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+
+ deleteOrganisationMembers({
+ organisationId,
+ memberIds: [organisationMemberId],
+ })
+ }
+ >
+ Delete
+
+
+
+
+
+ );
+};
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 member }
+
+
+
+
+ Invite organisation members
+
+
+ An email containing an invitation will be sent to each member.
+
+
+
+
+
+
+
+ );
+};
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 ?? Leave organisation }
+
+
+
+
+ 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.'}
+
+ )}
+
+
+
+ setOpen(false)}>
+ Cancel
+
+
+ leaveOrg({ organisationId })}
+ >
+ Leave
+
+
+
+
+
+ );
+};
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 }
+
+
+
+
+ Update organisation member
+
+
+ You are currently updating {organisationMemberName}.
+
+
+
+
+
+
+
+ );
+};
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 (
+
+
+ );
+};
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 (
+
+
+
+
+ General
+
+
+
+
+
+
+ Members
+
+
+
+
+
+
+ API Tokens
+
+
+
+
+
+
+ Webhooks
+
+
+
+
+
+
+ Billing
+
+
+
+ );
+};
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 (
+
+
+
+
+ General
+
+
+
+
+
+
+ Members
+
+
+
+
+
+
+ API Tokens
+
+
+
+
+
+
+ Webhooks
+
+
+
+ {IS_BILLING_ENABLED() && (
+
+
+
+ Billing
+
+
+ )}
+
+ );
+};
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,
+ ) && (
+
+ Manage
+
+ )}
+
+ 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,