feat: init
This commit is contained in:
28
apps/web/src/app/(dashboard)/settings/organisations/page.tsx
Normal file
28
apps/web/src/app/(dashboard)/settings/organisations/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title="Organisations"
|
||||||
|
subtitle="Manage all organisations you are currently associated with."
|
||||||
|
>
|
||||||
|
{/* Todo: Org - only display when no org created & user can create org */}
|
||||||
|
<CreateOrganisationDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<CurrentUserOrganisationsDataTable />
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{/* Todo: Orgs */}
|
||||||
|
<TeamInvitations />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
apps/web/src/app/(orgs)/orgs/[orgUrl]/layout.tsx
Normal file
67
apps/web/src/app/(orgs)/orgs/[orgUrl]/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<NextAuthProvider session={session}>
|
||||||
|
{/* Todo: Orgs don't need limits... right? */}
|
||||||
|
<LimitsProvider>
|
||||||
|
{/* {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||||
|
<LayoutBillingBanner
|
||||||
|
subscription={team.subscription}
|
||||||
|
teamId={team.id}
|
||||||
|
userRole={team.currentTeamMember.role}
|
||||||
|
/>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Todo: Orgs - Should we scope teams to orgs? */}
|
||||||
|
<Header user={user} teams={teams} />
|
||||||
|
|
||||||
|
<OrganisationProvider organisation={organisation}>
|
||||||
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
</OrganisationProvider>
|
||||||
|
|
||||||
|
<RefreshOnFocus />
|
||||||
|
</LimitsProvider>
|
||||||
|
</NextAuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/layout.tsx
Normal file
57
apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
<h1 className="text-4xl font-semibold">Organisation Settings</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||||
|
<DesktopNav className="hidden md:col-span-3 md:flex" />
|
||||||
|
<MobileNav className="col-span-12 mb-8 md:hidden" />
|
||||||
|
|
||||||
|
<div className="col-span-12 md:col-span-9">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader title="Members" subtitle="Manage organisation members or invite new members.">
|
||||||
|
<InviteOrganisationMembersDialog
|
||||||
|
organisationId={organisation.id}
|
||||||
|
currentUserRole={organisation.currentMember.role}
|
||||||
|
/>
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<OrganisationMemberPageDataTable
|
||||||
|
currentUserRole={organisation.currentMember.role}
|
||||||
|
organisationId={organisation.id}
|
||||||
|
organisationName={organisation.name}
|
||||||
|
organisationOwnerUserId={organisation.ownerUserId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/page.tsx
Normal file
57
apps/web/src/app/(orgs)/orgs/[orgUrl]/settings/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader
|
||||||
|
title="Organisation profile"
|
||||||
|
subtitle="Here you can edit your organisation details."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UpdateOrganisationForm
|
||||||
|
organisationId={organisation.id}
|
||||||
|
organisationName={organisation.name}
|
||||||
|
organisationUrl={organisation.url}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="mt-6 space-y-6">
|
||||||
|
<Alert
|
||||||
|
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Transfer or delete organisation</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
Please contact us at{' '}
|
||||||
|
<a target="_blank" className="font-bold" href="mailto:support@documenso.com">
|
||||||
|
support@documenso.com
|
||||||
|
</a>{' '}
|
||||||
|
to transfer or delete your organisation
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="w-screen max-w-lg px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<h1 className="text-4xl font-semibold">Invalid token</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
This token is invalid or has expired. Please contact your organisation for a new
|
||||||
|
invitation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Return</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Organisation invitation</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
You have been invited by <strong>{organisation.name}</strong> to join their organisation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-1 text-sm">
|
||||||
|
To accept this invitation you must create an account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/signup?email=${encodeURIComponent(email)}`}>Create account</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSessionUserTheInvitedUser = user.id === session.user?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-semibold">Invitation accepted!</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 mt-2 text-sm">
|
||||||
|
You have accepted an invitation from <strong>{organisation.name}</strong> to join their
|
||||||
|
organisation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isSessionUserTheInvitedUser ? (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/">Continue</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={`/signin?email=${encodeURIComponent(email)}`}>Continue to login</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { 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 { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -35,6 +35,19 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/organisations">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Building className="mr-2 h-5 w-5" />
|
||||||
|
Organisations
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/teams">
|
<Link href="/settings/teams">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { 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 { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@@ -38,6 +38,19 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/organisations">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith('/settings/organisations') && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Building className="mr-2 h-5 w-5" />
|
||||||
|
Organisations
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/teams">
|
<Link href="/settings/teams">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZCreateOrganisationMutationSchema } from '@documenso/trpc/server/organisation-router/schema';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type CreateOrganisationDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZCreateOrganisationFormSchema = ZCreateOrganisationMutationSchema.pick({
|
||||||
|
organisationName: true,
|
||||||
|
organisationUrl: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateOrganisationFormSchema>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button className="flex-shrink-0" variant="secondary">
|
||||||
|
Create organisation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create organisation</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Create an organisation to collaborate with teams.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organisationName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Organisation Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="bg-background"
|
||||||
|
{...field}
|
||||||
|
onChange={(event) => {
|
||||||
|
const oldGeneratedUrl = mapTextToUrl(field.value);
|
||||||
|
const newGeneratedUrl = mapTextToUrl(event.target.value);
|
||||||
|
|
||||||
|
const urlField = form.getValues('organisationUrl');
|
||||||
|
if (urlField === oldGeneratedUrl) {
|
||||||
|
form.setValue('organisationUrl', newGeneratedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
field.onChange(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="organisationUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Organisation URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{!form.formState.errors.organisationUrl && (
|
||||||
|
<span className="text-foreground/50 text-xs font-normal">
|
||||||
|
{field.value
|
||||||
|
? `${WEBAPP_BASE_URL}/orgs/${field.value}`
|
||||||
|
: 'A unique URL to identify your organisation'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
data-testid="dialog-create-organisation-button"
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Create organisation
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isDeletingOrganisationMember && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? <Button variant="secondary">Delete organisation member</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are about to remove the following user from{' '}
|
||||||
|
<span className="font-semibold">{organisationName}</span>.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral" padding="tight">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
|
||||||
|
secondaryText={organisationMemberEmail}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeletingOrganisationMember}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDeletingOrganisationMember}
|
||||||
|
onClick={async () =>
|
||||||
|
deleteOrganisationMembers({
|
||||||
|
organisationId,
|
||||||
|
memberIds: [organisationMemberId],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
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<typeof ZInviteOrganisationMembersFormSchema>;
|
||||||
|
|
||||||
|
export const InviteOrganisationMembersDialog = ({
|
||||||
|
currentUserRole,
|
||||||
|
organisationId,
|
||||||
|
trigger,
|
||||||
|
...props
|
||||||
|
}: InviteOrganisationMembersDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TInviteOrganisationMembersFormSchema>({
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? <Button variant="secondary">Invite member</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Invite organisation members</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
An email containing an invitation will be sent to each member.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{organisationMemberInvites.map((organisationMemberInvite, index) => (
|
||||||
|
<div className="flex w-full flex-row space-x-4" key={organisationMemberInvite.id}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`invitations.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel required>Email address</FormLabel>}
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`invitations.${index}.role`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
{index === 0 && <FormLabel required>Role</FormLabel>}
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserRole].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{ORGANISATION_MEMBER_ROLE_MAP[role] ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
index === 0 ? 'mt-8' : 'mt-0',
|
||||||
|
)}
|
||||||
|
disabled={organisationMemberInvites.length === 1}
|
||||||
|
onClick={() => removeOrganisationMemberInvite(index)}
|
||||||
|
>
|
||||||
|
<Trash className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => onAddOrganisationMemberInvite()}
|
||||||
|
>
|
||||||
|
<PlusCircle className="mr-2 h-4 w-4" />
|
||||||
|
Add more
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
{!form.formState.isSubmitting && <Mail className="mr-2 h-4 w-4" />}
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,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<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLeavingOrg && setOpen(value)}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ?? <Button variant="destructive">Leave organisation</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Are you sure?</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are about to leave the following organisation.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Alert variant="neutral" padding="tight">
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={organisationName.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={organisationName}
|
||||||
|
secondaryText={ORGANISATION_MEMBER_ROLE_MAP[role]}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{errorCode && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
{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.'}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset disabled={isLeavingOrg}>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isLeavingOrg}
|
||||||
|
onClick={async () => leaveOrg({ organisationId })}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZUpdateOrganisationMemberFormSchema = z.object({
|
||||||
|
role: z.nativeEnum(OrganisationMemberRole),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ZUpdateOrganisationMemberSchema = z.infer<typeof ZUpdateOrganisationMemberFormSchema>;
|
||||||
|
|
||||||
|
export const UpdateOrganisationMemberDialog = ({
|
||||||
|
currentUserRole,
|
||||||
|
trigger,
|
||||||
|
organisationId,
|
||||||
|
organisationMemberId,
|
||||||
|
organisationMemberName,
|
||||||
|
organisationMemberRole,
|
||||||
|
...props
|
||||||
|
}: UpdateOrganisationMemberDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<ZUpdateOrganisationMemberSchema>({
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
{trigger ?? <Button variant="secondary">Update organisation member</Button>}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update organisation member</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are currently updating <span className="font-bold">{organisationMemberName}.</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>Role</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="text-muted-foreground">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent className="w-full" position="popper">
|
||||||
|
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserRole].map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{ORGANISATION_MEMBER_ROLE_MAP[role] ?? role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<typeof ZUpdateOrganisationFormSchema>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Organisation Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mt-4">
|
||||||
|
<FormLabel required>Organisation URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
{!form.formState.errors.url && (
|
||||||
|
<span className="text-foreground/50 text-xs font-normal">
|
||||||
|
{field.value
|
||||||
|
? `${WEBAPP_BASE_URL}/orgs/${field.value}`
|
||||||
|
: 'A unique URL to identify your organisation'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-row justify-end space-x-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{form.formState.isDirty && (
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="transition-opacity"
|
||||||
|
disabled={!form.formState.isDirty}
|
||||||
|
loading={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
Update organisation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<HTMLDivElement>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||||
|
<Link href={settingsPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname === settingsPath && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-5 w-5" />
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={membersPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-5 w-5" />
|
||||||
|
Members
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={tokensPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<Braces className="mr-2 h-5 w-5" />
|
||||||
|
API Tokens
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={webhooksPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Webhook className="mr-2 h-5 w-5" />
|
||||||
|
Webhooks
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={billingPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="mr-2 h-5 w-5" />
|
||||||
|
Billing
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,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<HTMLDivElement>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Link href={settingsPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(settingsPath) &&
|
||||||
|
pathname.split('/').length === 4 &&
|
||||||
|
'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User className="mr-2 h-5 w-5" />
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={membersPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Key className="mr-2 h-5 w-5" />
|
||||||
|
Members
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={tokensPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||||
|
>
|
||||||
|
<Braces className="mr-2 h-5 w-5" />
|
||||||
|
API Tokens
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href={webhooksPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Webhook className="mr-2 h-5 w-5" />
|
||||||
|
Webhooks
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{IS_BILLING_ENABLED() && (
|
||||||
|
<Link href={billingPath}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-start',
|
||||||
|
pathname?.startsWith(billingPath) && 'bg-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="mr-2 h-5 w-5" />
|
||||||
|
Billing
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,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 (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Organisation',
|
||||||
|
accessorKey: 'name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Link href={`/orgs/${row.original.url}`} scroll={false}>
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={row.original.name.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">{row.original.name}</span>
|
||||||
|
}
|
||||||
|
secondaryText={`${WEBAPP_BASE_URL}/orgs/${row.original.url}`}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
{canExecuteOrganisationAction(
|
||||||
|
'MANAGE_ORGANISATION',
|
||||||
|
row.original.currentMember.role,
|
||||||
|
) && (
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/orgs/${row.original.url}/settings`}>Manage</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LeaveOrganisationDialog
|
||||||
|
organisationId={row.original.id}
|
||||||
|
organisationName={row.original.name}
|
||||||
|
role={row.original.currentMember.role}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={row.original.ownerUserId === row.original.currentMember.userId}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/3 py-4 pr-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||||
|
|
||||||
|
<div className="ml-2 flex flex-grow flex-col">
|
||||||
|
<Skeleton className="h-4 w-1/2 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-2/3 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row justify-end space-x-2">
|
||||||
|
<Skeleton className="h-10 w-20 rounded" />
|
||||||
|
<Skeleton className="h-10 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,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 (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Organisation Member',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return (
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={row.original.email.slice(0, 1).toUpperCase()}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">{row.original.email}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Role',
|
||||||
|
accessorKey: 'role',
|
||||||
|
cell: ({ row }) => ORGANISATION_MEMBER_ROLE_MAP[row.original.role] ?? row.original.role,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Invited At',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () =>
|
||||||
|
resendOrganisationMemberInvitation({
|
||||||
|
organisationId,
|
||||||
|
invitationId: row.original.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<History className="mr-2 h-4 w-4" />
|
||||||
|
Resend
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () =>
|
||||||
|
deleteOrganisationMemberInvitations({
|
||||||
|
organisationId,
|
||||||
|
invitationIds: [row.original.id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/2 py-4 pr-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||||
|
<Skeleton className="ml-2 h-4 w-1/3 max-w-[10rem]" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-6 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,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 (
|
||||||
|
<div>
|
||||||
|
<div className="my-4 flex flex-row items-center justify-between space-x-4">
|
||||||
|
<Input
|
||||||
|
defaultValue={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="members" asChild>
|
||||||
|
<Link href={pathname ?? '/'}>Active</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
|
||||||
|
<Link href={`${pathname}?tab=invites`}>Pending</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentTab === 'invites' ? (
|
||||||
|
<OrganisationMemberInvitesDataTable key="invites" organisationId={organisationId} />
|
||||||
|
) : (
|
||||||
|
<OrganisationMembersDataTable
|
||||||
|
key="members"
|
||||||
|
currentUserRole={currentUserRole}
|
||||||
|
organisationId={organisationId}
|
||||||
|
organisationName={organisationName}
|
||||||
|
organisationOwnerUserId={organisationOwnerUserId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Organisation Member',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const avatarFallbackText = row.original.user.name
|
||||||
|
? extractInitials(row.original.user.name)
|
||||||
|
: row.original.user.email.slice(0, 1).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarWithText
|
||||||
|
avatarClass="h-12 w-12"
|
||||||
|
avatarFallback={avatarFallbackText}
|
||||||
|
primaryText={
|
||||||
|
<span className="text-foreground/80 font-semibold">{row.original.user.name}</span>
|
||||||
|
}
|
||||||
|
secondaryText={row.original.user.email}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Role',
|
||||||
|
accessorKey: 'role',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
organisationOwnerUserId === row.original.userId
|
||||||
|
? 'Owner'
|
||||||
|
: ORGANISATION_MEMBER_ROLE_MAP[row.original.role],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Member Since',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
|
||||||
|
<UpdateOrganisationMemberDialog
|
||||||
|
currentUserRole={currentUserRole}
|
||||||
|
organisationId={row.original.organisationId}
|
||||||
|
organisationMemberId={row.original.id}
|
||||||
|
organisationMemberName={row.original.user.name ?? ''}
|
||||||
|
organisationMemberRole={row.original.role}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={
|
||||||
|
organisationOwnerUserId === row.original.userId ||
|
||||||
|
!isOrganisationRoleWithinUserHierarchy(currentUserRole, row.original.role)
|
||||||
|
}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
title="Update organisation member role"
|
||||||
|
>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Update role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteOrganisationMemberDialog
|
||||||
|
organisationId={organisationId}
|
||||||
|
organisationName={organisationName}
|
||||||
|
organisationMemberId={row.original.id}
|
||||||
|
organisationMemberName={row.original.user.name ?? ''}
|
||||||
|
organisationMemberEmail={row.original.user.email}
|
||||||
|
trigger={
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
disabled={
|
||||||
|
organisationOwnerUserId === row.original.userId ||
|
||||||
|
!isOrganisationRoleWithinUserHierarchy(currentUserRole, row.original.role)
|
||||||
|
}
|
||||||
|
title="Remove organisation member"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell className="w-1/2 py-4 pr-4">
|
||||||
|
<div className="flex w-full flex-row items-center">
|
||||||
|
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
|
||||||
|
|
||||||
|
<div className="ml-2 flex flex-grow flex-col">
|
||||||
|
<Skeleton className="h-4 w-1/3 max-w-[8rem]" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-1/2 max-w-[12rem]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-6 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
apps/web/src/providers/organisation.tsx
Normal file
33
apps/web/src/providers/organisation.tsx
Normal file
@@ -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<Organisation | null>(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 (
|
||||||
|
<OrganisationContext.Provider value={organisation}>{children}</OrganisationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
packages/email/templates/organisation-invite.tsx
Normal file
110
packages/email/templates/organisation-invite.tsx
Normal file
@@ -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 (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="mx-auto my-auto font-sans">
|
||||||
|
<Section className="bg-white text-slate-500">
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
|
||||||
|
<TemplateImage
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
className="mb-4 h-6 p-2"
|
||||||
|
staticAsset="logo.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<TemplateImage
|
||||||
|
className="mx-auto"
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
staticAsset="add-user.png"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="p-2 text-slate-500">
|
||||||
|
<Text className="text-center text-lg font-medium text-black">
|
||||||
|
Join {organisationName} on Documenso
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base">
|
||||||
|
You have been invited to join the following organisation
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-base font-medium text-slate-600">
|
||||||
|
{formatOrganisationUrl(organisationUrl, baseUrl)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base">
|
||||||
|
by <span className="text-slate-900">{senderName}</span>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Todo: Orgs - Display warnings. */}
|
||||||
|
|
||||||
|
<Section className="mb-6 mt-6 text-center">
|
||||||
|
<Button
|
||||||
|
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||||
|
href={`${baseUrl}/organisation/invite/${token}`}
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter isDocument={false} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrganisationInviteEmailTemplate;
|
||||||
30
packages/lib/constants/organisations.ts
Normal file
30
packages/lib/constants/organisations.ts
Normal file
@@ -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<keyof typeof OrganisationMemberRole, string> = {
|
||||||
|
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<string, OrganisationMemberRole[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<OrganisationMemberRole, OrganisationMemberRole[]>;
|
||||||
@@ -79,6 +79,10 @@ export const PROTECTED_TEAM_URLS = [
|
|||||||
'logout',
|
'logout',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
'malware',
|
'malware',
|
||||||
|
'org',
|
||||||
|
'orgs',
|
||||||
|
'organisation',
|
||||||
|
'organisations',
|
||||||
'newsletter',
|
'newsletter',
|
||||||
'policy',
|
'policy',
|
||||||
'privacy',
|
'privacy',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
82
packages/lib/server-only/organisation/create-organisation.ts
Normal file
82
packages/lib/server-only/organisation/create-organisation.ts
Normal file
@@ -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<void> => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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<typeof data>;
|
||||||
|
};
|
||||||
@@ -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<typeof data>;
|
||||||
|
};
|
||||||
76
packages/lib/server-only/organisation/find-organisations.ts
Normal file
76
packages/lib/server-only/organisation/find-organisations.ts
Normal file
@@ -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<typeof maskedData>;
|
||||||
|
};
|
||||||
96
packages/lib/server-only/organisation/get-organisation.ts
Normal file
96
packages/lib/server-only/organisation/get-organisation.ts
Normal file
@@ -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],
|
||||||
|
};
|
||||||
|
};
|
||||||
33
packages/lib/server-only/organisation/get-organisations.ts
Normal file
33
packages/lib/server-only/organisation/get-organisations.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetOrganisationsOptions = {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
export type GetOrganisationsResponse = Awaited<ReturnType<typeof getOrganisations>>;
|
||||||
|
|
||||||
|
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],
|
||||||
|
}));
|
||||||
|
};
|
||||||
55
packages/lib/server-only/organisation/leave-organisation.ts
Normal file
55
packages/lib/server-only/organisation/leave-organisation.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
56
packages/lib/server-only/organisation/update-organisation.ts
Normal file
56
packages/lib/server-only/organisation/update-organisation.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,7 +3,13 @@ import { hash } from '@node-rs/bcrypt';
|
|||||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
import { updateSubscriptionItemQuantity } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity';
|
||||||
import { prisma } from '@documenso/prisma';
|
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 { IS_BILLING_ENABLED } from '../../constants/app';
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
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({
|
const [acceptedTeamInvites, acceptedOrganisationInvites] = await Promise.all([
|
||||||
where: {
|
prisma.teamMemberInvite.findMany({
|
||||||
email: {
|
where: {
|
||||||
equals: email,
|
email: {
|
||||||
mode: Prisma.QueryMode.insensitive,
|
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.
|
// If an error occurs, reset the invitation to not accepted.
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
acceptedTeamInvites.map(async (invite) =>
|
[
|
||||||
prisma
|
acceptedTeamInvites.map(async (invite) =>
|
||||||
.$transaction(
|
prisma
|
||||||
async (tx) => {
|
.$transaction(
|
||||||
await tx.teamMember.create({
|
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: {
|
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,
|
userId: user.id,
|
||||||
role: invite.role,
|
role: invite.role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.teamMemberInvite.delete({
|
await tx.organisationMemberInvite.delete({
|
||||||
where: {
|
where: {
|
||||||
id: invite.id,
|
id: invite.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
})
|
||||||
if (!IS_BILLING_ENABLED()) {
|
.catch(async () => {
|
||||||
return;
|
await prisma.organisationMemberInvite.update({
|
||||||
}
|
|
||||||
|
|
||||||
const team = await tx.team.findFirstOrThrow({
|
|
||||||
where: {
|
where: {
|
||||||
id: invite.teamId,
|
id: invite.id,
|
||||||
},
|
},
|
||||||
include: {
|
data: {
|
||||||
members: {
|
status: InviteStatus.PENDING,
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
subscription: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}),
|
||||||
if (team.subscription) {
|
),
|
||||||
await updateSubscriptionItemQuantity({
|
].flat(),
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the user record with a new or existing Stripe customer record.
|
// Update the user record with a new or existing Stripe customer record.
|
||||||
|
|||||||
66
packages/lib/utils/organisations.ts
Normal file
66
packages/lib/utils/organisations.ts
Normal file
@@ -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);
|
||||||
|
};
|
||||||
308
packages/trpc/server/organisation-router/router.ts
Normal file
308
packages/trpc/server/organisation-router/router.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
171
packages/trpc/server/organisation-router/schema.ts
Normal file
171
packages/trpc/server/organisation-router/schema.ts
Normal file
@@ -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
|
||||||
|
>;
|
||||||
@@ -4,6 +4,7 @@ import { authRouter } from './auth-router/router';
|
|||||||
import { cryptoRouter } from './crypto/router';
|
import { cryptoRouter } from './crypto/router';
|
||||||
import { documentRouter } from './document-router/router';
|
import { documentRouter } from './document-router/router';
|
||||||
import { fieldRouter } from './field-router/router';
|
import { fieldRouter } from './field-router/router';
|
||||||
|
import { organisationRouter } from './organisation-router/router';
|
||||||
import { profileRouter } from './profile-router/router';
|
import { profileRouter } from './profile-router/router';
|
||||||
import { recipientRouter } from './recipient-router/router';
|
import { recipientRouter } from './recipient-router/router';
|
||||||
import { shareLinkRouter } from './share-link-router/router';
|
import { shareLinkRouter } from './share-link-router/router';
|
||||||
@@ -25,6 +26,7 @@ export const appRouter = router({
|
|||||||
shareLink: shareLinkRouter,
|
shareLink: shareLinkRouter,
|
||||||
apiToken: apiTokenRouter,
|
apiToken: apiTokenRouter,
|
||||||
singleplayer: singleplayerRouter,
|
singleplayer: singleplayerRouter,
|
||||||
|
organisation: organisationRouter,
|
||||||
team: teamRouter,
|
team: teamRouter,
|
||||||
template: templateRouter,
|
template: templateRouter,
|
||||||
webhook: webhookRouter,
|
webhook: webhookRouter,
|
||||||
|
|||||||
Reference in New Issue
Block a user