feat: add organisations (#1820)

This commit is contained in:
David Nguyen
2025-06-10 11:49:52 +10:00
committed by GitHub
parent 0b37f19641
commit e6dc237ad2
631 changed files with 37616 additions and 25695 deletions

View File

@ -0,0 +1,137 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { AlertTriangle } from 'lucide-react';
import { match } from 'ts-pattern';
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const OrganisationBillingBanner = () => {
const { _ } = useLingui();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const organisation = useOptionalCurrentOrganisation();
const { mutateAsync: manageSubscription, isPending } =
trpc.billing.subscription.manage.useMutation();
const handleCreatePortal = async (organisationId: string) => {
try {
const { redirectUrl } = await manageSubscription({ organisationId });
window.open(redirectUrl, '_blank');
setIsOpen(false);
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
const subscriptionStatus = organisation?.subscription?.status;
if (
!organisation ||
subscriptionStatus === undefined ||
subscriptionStatus === SubscriptionStatus.ACTIVE
) {
return null;
}
return (
<>
<div
className={cn({
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400':
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'bg-destructive text-destructive-foreground':
subscriptionStatus === SubscriptionStatus.INACTIVE,
})}
>
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
.with(SubscriptionStatus.INACTIVE, () => <Trans>Restricted Access</Trans>)
.exhaustive()}
</div>
<Button
variant="outline"
className={cn({
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
subscriptionStatus === SubscriptionStatus.PAST_DUE,
'text-destructive-foreground hover:bg-destructive-foreground hover:text-white':
subscriptionStatus === SubscriptionStatus.INACTIVE,
})}
disabled={isPending}
onClick={() => setIsOpen(true)}
size="sm"
>
<Trans>Resolve</Trans>
</Button>
</div>
</div>
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
<DialogContent>
<DialogTitle>
<Trans>Payment overdue</Trans>
</DialogTitle>
{match(subscriptionStatus)
.with(SubscriptionStatus.PAST_DUE, () => (
<DialogDescription>
<Trans>
Your payment for teams is overdue. Please settle the payment to avoid any service
disruptions.
</Trans>
</DialogDescription>
))
.with(SubscriptionStatus.INACTIVE, () => (
<DialogDescription>
<Trans>
Due to an unpaid invoice, your team has been restricted. Please settle the payment
to restore full access to your team.
</Trans>
</DialogDescription>
))
.otherwise(() => null)}
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
<DialogFooter>
<Button loading={isPending} onClick={async () => handleCreatePortal(organisation.id)}>
<Trans>Resolve payment</Trans>
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
);
};

View File

@ -0,0 +1,58 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type OrganisationBillingPortalButtonProps = {
buttonProps?: React.ComponentProps<typeof Button>;
};
export const OrganisationBillingPortalButton = ({
buttonProps,
}: OrganisationBillingPortalButtonProps) => {
const organisation = useCurrentOrganisation();
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: manageSubscription, isPending } =
trpc.billing.subscription.manage.useMutation();
const canManageBilling = canExecuteOrganisationAction(
'MANAGE_BILLING',
organisation.currentOrganisationRole,
);
const handleCreatePortal = async () => {
try {
const { redirectUrl } = await manageSubscription({ organisationId: organisation.id });
window.open(redirectUrl, '_blank');
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We are unable to proceed to the billing portal at this time. Please try again, or contact support.`,
),
variant: 'destructive',
duration: 10000,
});
}
};
return (
<Button
{...buttonProps}
onClick={async () => handleCreatePortal()}
loading={isPending}
disabled={!canManageBilling}
>
<Trans>Manage billing</Trans>
</Button>
);
};

View File

@ -0,0 +1,198 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { OrganisationMemberInviteStatus } from '@prisma/client';
import { AnimatePresence } from 'framer-motion';
import { BellIcon } from 'lucide-react';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const OrganisationInvitations = ({ className }: { className?: string }) => {
const { data, isLoading } = trpc.organisation.member.invite.getMany.useQuery({
status: OrganisationMemberInviteStatus.PENDING,
});
return (
<AnimatePresence>
{data && data.length > 0 && !isLoading && (
<AnimateGenericFadeInOut>
<Alert variant="secondary" className={className}>
<div className="flex h-full flex-row items-center p-2">
<BellIcon className="mr-4 h-5 w-5 text-blue-800" />
<AlertDescription className="mr-2">
<Plural
value={data.length}
one={
<span>
You have <strong>1</strong> pending invitation
</span>
}
other={
<span>
You have <strong>#</strong> pending invitations
</span>
}
/>
</AlertDescription>
<Dialog>
<DialogTrigger asChild>
<button className="ml-auto text-sm font-medium text-blue-700 hover:text-blue-600">
<Trans>View invites</Trans>
</button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Pending invitations</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Plural
value={data.length}
one={
<span>
You have <strong>1</strong> pending invitation
</span>
}
other={
<span>
You have <strong>#</strong> pending invitations
</span>
}
/>
</DialogDescription>
</DialogHeader>
<ul className="-mx-6 -mb-6 max-h-[80vh] divide-y overflow-auto px-6 pb-6 xl:max-h-[70vh]">
{data.map((invitation) => (
<li key={invitation.id}>
<Alert variant="neutral" className="p-0 px-4">
<AvatarWithText
avatarSrc={formatAvatarUrl(invitation.organisation.avatarImageId)}
className="w-full max-w-none py-4"
avatarFallback={invitation.organisation.name.slice(0, 1)}
primaryText={
<span className="text-foreground/80 font-semibold">
{invitation.organisation.name}
</span>
}
secondaryText={`/o/${invitation.organisation.url}`}
rightSideComponent={
<div className="ml-auto space-x-2">
<DeclineOrganisationInvitationButton token={invitation.token} />
<AcceptOrganisationInvitationButton token={invitation.token} />
</div>
}
/>
</Alert>
</li>
))}
</ul>
</DialogContent>
</Dialog>
</div>
</Alert>
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
);
};
const AcceptOrganisationInvitationButton = ({ token }: { token: string }) => {
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const {
mutateAsync: acceptOrganisationInvitation,
isPending,
isSuccess,
} = trpc.organisation.member.invite.accept.useMutation({
onSuccess: async () => {
await refreshSession();
toast({
title: _(msg`Success`),
description: _(msg`Invitation accepted`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to join this organisation at this time.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Button
onClick={async () => acceptOrganisationInvitation({ token })}
loading={isPending}
disabled={isPending || isSuccess}
>
<Trans>Accept</Trans>
</Button>
);
};
const DeclineOrganisationInvitationButton = ({ token }: { token: string }) => {
const { _ } = useLingui();
const { toast } = useToast();
const { refreshSession } = useSession();
const {
mutateAsync: declineOrganisationInvitation,
isPending,
isSuccess,
} = trpc.organisation.member.invite.decline.useMutation({
onSuccess: async () => {
await refreshSession();
toast({
title: _(msg`Success`),
description: _(msg`Invitation declined`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to decline this invitation at this time.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Button
onClick={async () => declineOrganisationInvitation({ token })}
loading={isPending}
disabled={isPending || isSuccess}
variant="ghost"
>
<Trans>Decline</Trans>
</Button>
);
};