feat: wip

This commit is contained in:
David Nguyen
2023-12-27 13:04:24 +11:00
parent f7cf33c61b
commit 9d626473c8
140 changed files with 9604 additions and 536 deletions

View File

@@ -0,0 +1,85 @@
import { DateTime } from 'luxon';
import type Stripe from 'stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { BillingPortalButton } from '~/app/(dashboard)/settings/billing/billing-portal-button';
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
import TeamBillingInvoicesDataTable from '~/components/(teams)/tables/team-billing-invoices-data-table';
export type TeamsSettingsBillingPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
const isUserOwnerOfTeam = team.ownerUserId === session.user.id;
let teamSubscription: Stripe.Subscription | null = null;
if (team.subscriptionId) {
teamSubscription = await stripe.subscriptions.retrieve(team.subscriptionId);
}
const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
if (!subscription) {
return 'No payment required';
}
const numberOfSeats = subscription.items.data[0].quantity ?? 0;
const formattedTeamMemberQuanity = numberOfSeats > 1 ? `${numberOfSeats} members` : '1 member';
const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
'LLL dd, yyyy',
);
return `${formattedTeamMemberQuanity} • Monthly • Renews: ${formattedDate}`;
};
return (
<div>
<SettingsHeader title="Billing" subtitle="Your subscription is currently active." />
<Card gradient className="shadow-sm">
<CardContent className="flex flex-row items-center justify-between p-4">
<div className="flex flex-col text-sm">
<p className="text-foreground font-semibold">
Current plan: {teamSubscription ? 'Team' : 'Community Team'}
</p>
<p className="text-muted-foreground mt-0.5">
{formatTeamSubscriptionDetails(teamSubscription)}
</p>
</div>
{teamSubscription && (
<div
title={
isUserOwnerOfTeam
? 'Manage your team subscription.'
: 'You must be the owner of this team to directly manage the billing.'
}
>
<BillingPortalButton buttonProps={{ disabled: !isUserOwnerOfTeam }} />
</div>
)}
</CardContent>
</Card>
<section className="mt-6">
<TeamBillingInvoicesDataTable teamId={team.id} />
</section>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { notFound } from 'next/navigation';
import { canExecuteTeamAction } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import { DesktopNav } from '~/components/(teams)/settings/layout/desktop-nav';
import { MobileNav } from '~/components/(teams)/settings/layout/mobile-nav';
export type DashboardSettingsLayoutProps = {
children: React.ReactNode;
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsLayout({
children,
params: { teamUrl },
}: DashboardSettingsLayoutProps) {
const session = await getRequiredServerComponentSession();
try {
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw new Error(AppErrorCode.UNAUTHORIZED);
}
} catch (e) {
const error = AppError.parseError(e);
if (error.code === 'P2025') {
notFound();
}
throw e;
}
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">Team 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>
);
}

View File

@@ -0,0 +1,34 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
import InviteTeamMembersDialog from '~/components/(teams)/dialogs/invite-team-member-dialog';
import TeamsMemberPageDataTable from '~/components/(teams)/tables/teams-member-page-data-table';
export type TeamsSettingsMembersPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
return (
<div>
<SettingsHeader title="Members" subtitle="Manage the members or invite new members.">
<InviteTeamMembersDialog teamId={team.id} />
</SettingsHeader>
<TeamsMemberPageDataTable
teamId={team.id}
teamName={team.name}
teamOwnerUserId={team.ownerUserId}
/>
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { CheckCircle2, Clock } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import SettingsHeader from '~/components/(dashboard)/settings/layout/header';
import AddTeamEmailDialog from '~/components/(teams)/dialogs/add-team-email-dialog';
import DeleteTeamDialog from '~/components/(teams)/dialogs/delete-team-dialog';
import TransferTeamDialog from '~/components/(teams)/dialogs/transfer-team-dialog';
import UpdateTeamForm from '~/components/(teams)/forms/update-team-form';
import TeamEmailDropdown from './team-email-dropdown';
import { TeamTransferStatus } from './team-transfer-status';
export type TeamsSettingsPageProps = {
params: {
teamUrl: string;
};
};
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
const { teamUrl } = params;
const session = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: session.user.id, teamUrl });
const isTransferVerificationExpired =
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
return (
<div>
<SettingsHeader title="Team Profile" subtitle="Here you can edit your team's details." />
<TeamTransferStatus
teamId={team.id}
transferVerification={team.transferVerification}
className="mb-4"
/>
<UpdateTeamForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="space-y-6">
{(team.teamEmail || team.emailVerification) && (
<section className="mt-6 rounded-lg bg-gray-50/70 p-6 pb-2">
<h3 className="font-medium">Team email</h3>
<p className="text-muted-foreground text-sm">
You can view documents associated with this email and use this identity when sending
documents.
</p>
<hr className="border-border/50 mt-2" />
<div className="flex flex-row items-center justify-between py-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={recipientInitials(
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
)}
primaryText={
<span className="text-foreground/80 text-sm font-semibold">
{team.teamEmail?.name || team.emailVerification?.name}
</span>
}
secondaryText={
<span className="text-sm">
{team.teamEmail?.email || team.emailVerification?.email}
</span>
}
/>
<div className="flex flex-row items-center pr-2">
<div className="text-muted-foreground mr-4 flex flex-row items-center text-sm xl:mr-8">
{team.teamEmail ? (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
Active
</>
) : team.emailVerification && team.emailVerification.expiresAt < new Date() ? (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
Expired
</>
) : (
team.emailVerification && (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
Awaiting email confirmation
</>
)
)}
</div>
<TeamEmailDropdown team={team} />
</div>
</div>
</section>
)}
{!team.teamEmail && !team.emailVerification && (
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
<div>
<h3 className="font-medium">Team email</h3>
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
<li>Display this name and email when sending documents</li>
<li>View documents associated with this email</li>
</ul>
</div>
<AddTeamEmailDialog teamId={team.id} />
</div>
)}
{team.ownerUserId === session.user.id && (
<>
{isTransferVerificationExpired && (
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
<div>
<h3 className="font-medium">Transfer team</h3>
<p className="text-muted-foreground text-sm">
Transfer the ownership of the team to another team member.
</p>
</div>
<TransferTeamDialog
ownerUserId={team.ownerUserId}
teamId={team.id}
teamName={team.name}
/>
</div>
)}
<div className="flex flex-row items-center justify-between rounded-lg bg-gray-50/70 p-6">
<div>
<h3 className="font-medium">Delete team</h3>
<p className="text-muted-foreground text-sm">
This team, and any associated data excluding billing invoices will be permanently
deleted.
</p>
</div>
<DeleteTeamDialog teamId={team.id} teamName={team.name} />
</div>
</>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import { useRouter } from 'next/navigation';
import { Edit, Loader, Mail, MoreHorizontal, X } from 'lucide-react';
import type { getTeamByUrl } from '@documenso/lib/server-only/team/get-teams';
import { trpc } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import UpdateTeamEmailDialog from '~/components/(teams)/dialogs/update-team-email-dialog';
export type TeamsSettingsPageProps = {
team: Awaited<ReturnType<typeof getTeamByUrl>>;
};
export default function TeamEmailDropdown({ team }: TeamsSettingsPageProps) {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: resendEmailVerification, isLoading: isResendingEmailVerification } =
trpc.team.resendTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Email verification has been resent',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to resend verification at this time. Please try again.',
});
},
});
const { mutateAsync: deleteTeamEmail, isLoading: isDeletingTeamEmail } =
trpc.team.deleteTeamEmail.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Team email has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove team email at this time. Please try again.',
});
},
});
const { mutateAsync: deleteTeamEmailVerification, isLoading: isDeletingTeamEmailVerification } =
trpc.team.deleteTeamEmailVerification.useMutation({
onSuccess: () => {
toast({
title: 'Success',
description: 'Email verification has been removed',
duration: 5000,
});
},
onError: () => {
toast({
title: 'Something went wrong',
variant: 'destructive',
duration: 10000,
description: 'Unable to remove email verification at this time. Please try again.',
});
},
});
const onRemove = async () => {
if (team.teamEmail) {
await deleteTeamEmail({ teamId: team.id });
}
if (team.emailVerification) {
await deleteTeamEmailVerification({ teamId: team.id });
}
router.refresh();
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
{!team.teamEmail && team.emailVerification && (
<DropdownMenuItem
disabled={isResendingEmailVerification}
onClick={(e) => {
e.preventDefault();
void resendEmailVerification({ teamId: team.id });
}}
>
{isResendingEmailVerification ? (
<Loader className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Resend verification
</DropdownMenuItem>
)}
{team.teamEmail && (
<UpdateTeamEmailDialog
teamEmail={team.teamEmail}
trigger={
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Edit className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
}
/>
)}
<DropdownMenuItem
disabled={isDeletingTeamEmail || isDeletingTeamEmailVerification}
onClick={async () => onRemove()}
>
<X className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import type { TeamTransferVerification } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type TeamTransferStatusProps = {
className?: string;
teamId: number;
transferVerification: TeamTransferVerification | null;
};
export const TeamTransferStatus = ({
className,
teamId,
transferVerification,
}: TeamTransferStatusProps) => {
const router = useRouter();
const { toast } = useToast();
const isExpired = transferVerification && isTokenExpired(transferVerification.expiresAt);
const { mutateAsync: deleteTeamTransferRequest, isLoading } =
trpc.team.deleteTeamTransferRequest.useMutation({
onSuccess: () => {
if (!isExpired) {
toast({
title: 'Success',
description: 'The team transfer invitation has been successfully deleted.',
duration: 5000,
});
}
router.refresh();
},
onError: () => {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to remove this transfer. Please try again or contact support.',
});
},
});
return (
<AnimatePresence>
{transferVerification && (
<motion.div
className={cn(
'flex flex-row items-center justify-between rounded-lg border-2 border-yellow-400 bg-yellow-200 px-6 py-4 dark:border-yellow-600 dark:bg-yellow-400',
className,
)}
initial={{
opacity: 0,
}}
animate={{
opacity: 1,
}}
exit={{
opacity: 0,
}}
>
<div className="text-yellow-900">
<h3 className="font-medium">
{isExpired ? 'Team transfer request expired' : 'Team transfer in progress'}
</h3>
{isExpired ? (
<p className="text-sm">
The team transfer request to <strong>{transferVerification.name}</strong> has
expired.
</p>
) : (
<section className="text-sm">
<p>
A request to transfer the ownership of this team has been sent to{' '}
<strong>{transferVerification.name}</strong>
</p>
<p>If they accept this request, the team will be transferred to their account.</p>
</section>
)}
</div>
<Button
onClick={async () => deleteTeamTransferRequest({ teamId })}
loading={isLoading}
variant="destructive"
className="ml-auto mt-2"
>
{isExpired ? 'Close' : 'Cancel'}
</Button>
</motion.div>
)}
</AnimatePresence>
);
};