feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
parent 9183f668d3
commit 383b5f78f0
898 changed files with 31175 additions and 24615 deletions

View File

@ -0,0 +1,9 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/admin/stats');
}
export default function AdminPage() {
// Redirect page.
}

View File

@ -0,0 +1,120 @@
import { Trans } from '@lingui/react/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getLoaderSession } from 'server/utils/get-loader-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export function loader() {
const { user } = getLoaderSession();
if (!user || !isAdmin(user)) {
throw redirect('/documents');
}
}
export default function AdminLayout() {
const { pathname } = useLocation();
return (
<div className="mx-auto mt-16 w-full max-w-screen-xl px-4 md:px-8">
<div className="grid grid-cols-12 md:mt-8 md:gap-8">
<div
className={cn(
'col-span-12 flex gap-x-2.5 gap-y-2 overflow-hidden overflow-x-auto md:col-span-3 md:flex md:flex-col',
)}
>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/stats') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/stats">
<BarChart3 className="mr-2 h-5 w-5" />
<Trans>Stats</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/users">
<Users className="mr-2 h-5 w-5" />
<Trans>Users</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/documents') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/documents">
<FileStack className="mr-2 h-5 w-5" />
<Trans>Documents</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/subscriptions">
<Wallet2 className="mr-2 h-5 w-5" />
<Trans>Subscriptions</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/leaderboard') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/leaderboard">
<Trophy className="mr-2 h-5 w-5" />
<Trans>Leaderboard</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/banner') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/site-settings">
<Settings className="mr-2 h-5 w-5" />
<Trans>Site Settings</Trans>
</Link>
</Button>
</div>
<div className="col-span-12 mt-12 md:col-span-9 md:mt-0">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,165 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { trpc } from '@documenso/trpc/react';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@documenso/ui/primitives/accordion';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AdminDocumentDeleteDialog } from '~/components/dialogs/admin-document-delete-dialog';
import { DocumentStatus } from '~/components/general/document/document-status';
import { AdminDocumentRecipientItemTable } from '~/components/tables/admin-document-recipient-item-table';
import type { Route } from './+types/documents.$id';
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.id);
if (isNaN(id)) {
throw redirect('/admin/documents');
}
const document = await getEntireDocument({ id });
return { document };
}
export default function AdminDocumentDetailsPage({ loaderData }: Route.ComponentProps) {
const { document } = loaderData;
const { _, i18n } = useLingui();
const { toast } = useToast();
const { mutate: resealDocument, isPending: isResealDocumentLoading } =
trpc.admin.resealDocument.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Document resealed`),
});
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`Failed to reseal document`),
variant: 'destructive',
});
},
});
return (
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h1 className="text-2xl font-semibold">{document.title}</h1>
<DocumentStatus status={document.status} />
</div>
{document.deletedAt && (
<Badge size="large" variant="destructive">
<Trans>Deleted</Trans>
</Badge>
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div>
<Trans>Created on</Trans>: {i18n.date(document.createdAt, DateTime.DATETIME_MED)}
</div>
<div>
<Trans>Last updated at</Trans>: {i18n.date(document.updatedAt, DateTime.DATETIME_MED)}
</div>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">
<Trans>Admin Actions</Trans>
</h2>
<div className="mt-2 flex gap-x-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
loading={isResealDocumentLoading}
disabled={document.recipients.some(
(recipient) => recipient.signingStatus !== SigningStatus.SIGNED,
)}
onClick={() => resealDocument({ id: document.id })}
>
<Trans>Reseal document</Trans>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-[40ch]">
<Trans>
Attempts sealing the document again, useful for after a code change has occurred to
resolve an erroneous document.
</Trans>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="outline" asChild>
<Link to={`/admin/users/${document.userId}`}>
<Trans>Go to owner</Trans>
</Link>
</Button>
</div>
<hr className="my-4" />
<h2 className="text-lg font-semibold">
<Trans>Recipients</Trans>
</h2>
<div className="mt-4">
<Accordion type="multiple" className="space-y-4">
{document.recipients.map((recipient) => (
<AccordionItem
key={recipient.id}
value={recipient.id.toString()}
className="rounded-lg border"
>
<AccordionTrigger className="px-4">
<div className="flex items-center gap-x-4">
<h4 className="font-semibold">{recipient.name}</h4>
<Badge size="small" variant="neutral">
{recipient.email}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="border-t px-4 pt-4">
<AdminDocumentRecipientItemTable recipient={recipient} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<hr className="my-4" />
{document && <AdminDocumentDeleteDialog document={document} />}
</div>
);
}

View File

@ -0,0 +1,170 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { DocumentStatus } from '~/components/general/document/document-status';
export default function AdminDocumentsPage() {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [term, setTerm] = useState(() => searchParams?.get?.('term') ?? '');
const debouncedTerm = useDebouncedValue(term, 500);
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const { data: findDocumentsData, isPending: isFindDocumentsLoading } =
trpc.admin.findDocuments.useQuery(
{
query: debouncedTerm,
page: page || 1,
perPage: perPage || 20,
},
{
placeholderData: (previousData) => previousData,
},
);
const results = findDocumentsData ?? {
data: [],
perPage: 20,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: _(msg`Title`),
accessorKey: 'title',
cell: ({ row }) => {
return (
<Link
to={`/admin/documents/${row.original.id}`}
className="block max-w-[5rem] truncate font-medium hover:underline md:max-w-[10rem]"
>
{row.original.title}
</Link>
);
},
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
{
header: _(msg`Owner`),
accessorKey: 'owner',
cell: ({ row }) => {
const avatarFallbackText = row.original.user.name
? extractInitials(row.original.user.name)
: row.original.user.email.slice(0, 1).toUpperCase();
return (
<Tooltip delayDuration={200}>
<TooltipTrigger>
<Link to={`/admin/users/${row.original.user.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
</Link>
</TooltipTrigger>
<TooltipContent className="flex max-w-xs items-center gap-2">
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-xs text-gray-400">
{avatarFallbackText}
</AvatarFallback>
</Avatar>
<div className="text-muted-foreground flex flex-col text-sm">
<span>{row.original.user.name}</span>
<span>{row.original.user.email}</span>
</div>
</TooltipContent>
</Tooltip>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => i18n.date(row.original.updatedAt),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
const onPaginationChange = (newPage: number, newPerPage: number) => {
updateSearchParams({
page: newPage,
perPage: newPerPage,
});
};
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Manage documents</Trans>
</h2>
<div className="mt-8">
<div>
<Input
type="search"
placeholder={_(msg`Search by document title`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<div className="relative mt-4">
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage ?? 20}
currentPage={results.currentPage ?? 1}
totalPages={results.totalPages ?? 1}
onPaginationChange={onPaginationChange}
>
{(table) => (
<DataTablePagination additionalInformation="VisibleCount" table={table} />
)}
</DataTable>
{isFindDocumentsLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,66 @@
import { Trans } from '@lingui/react/macro';
import { getSigningVolume } from '@documenso/lib/server-only/admin/get-signing-volume';
import { AdminLeaderboardTable } from '~/components/tables/admin-leaderboard-table';
import type { Route } from './+types/leaderboard';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const rawSortBy = url.searchParams.get('sortBy') || 'signingVolume';
const rawSortOrder = url.searchParams.get('sortOrder') || 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortOrder = (['asc', 'desc'].includes(rawSortOrder) ? rawSortOrder : 'desc') as
| 'asc'
| 'desc';
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const sortBy = (
['name', 'createdAt', 'signingVolume'].includes(rawSortBy) ? rawSortBy : 'signingVolume'
) as 'name' | 'createdAt' | 'signingVolume';
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const { leaderboard: signingVolume, totalPages } = await getSigningVolume({
search,
page,
perPage,
sortBy,
sortOrder,
});
return {
signingVolume,
totalPages,
page,
perPage,
sortBy,
sortOrder,
};
}
export default function Leaderboard({ loaderData }: Route.ComponentProps) {
const { signingVolume, totalPages, page, perPage, sortBy, sortOrder } = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Signing Volume</Trans>
</h2>
<div className="mt-8">
<AdminLeaderboardTable
signingVolume={signingVolume}
totalPages={totalPages}
page={page}
perPage={perPage}
sortBy={sortBy}
sortOrder={sortOrder}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,224 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/site-settings';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export async function loader() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return { banner };
}
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;
const { toast } = useToast();
const { _ } = useLingui();
const { revalidate } = useRevalidator();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const { mutateAsync: updateSiteSetting, isPending: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: _(msg`Banner Updated`),
description: _(msg`Your banner has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
variant: 'destructive',
description: _(
msg`We encountered an unknown error while attempting to update the banner. Please try again later.`,
),
});
}
};
return (
<div>
<SettingsHeader
title={_(msg`Site Settings`)}
subtitle={_(msg`Manage your site settings here`)}
/>
<div className="mt-8">
<div>
<h2 className="font-semibold">
<Trans>Site Banner</Trans>
</h2>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
The site banner is a message that is shown at the top of the site. It can be used to
display important information to your users.
</Trans>
</p>
<Form {...form}>
<form
className="mt-4 flex flex-col rounded-md"
onSubmit={form.handleSubmit(onBannerUpdate)}
>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset
className="flex flex-col gap-4 md:flex-row"
disabled={!enabled}
aria-disabled={!enabled}
>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
<Trans>The content to show in the banner, HTML is allowed</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
loading={isUpdateSiteSettingLoading}
className="mt-4 justify-end self-end"
>
<Trans>Update Banner</Trans>
</Button>
</form>
</Form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,177 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import {
File,
FileCheck,
FileClock,
FileCog,
FileEdit,
Mail,
MailOpen,
PenTool,
UserPlus,
UserSquare2,
Users,
} from 'lucide-react';
import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats';
import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats';
import {
getUserWithSignedDocumentMonthlyGrowth,
getUsersCount,
getUsersWithSubscriptionsCount,
} from '@documenso/lib/server-only/admin/get-users-stats';
import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion';
import { env } from '@documenso/lib/utils/env';
import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart';
import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents';
import { CardMetric } from '~/components/general/metric-card';
import type { Route } from './+types/stats';
export async function loader() {
const [
usersCount,
usersWithSubscriptionsCount,
docStats,
recipientStats,
signerConversionMonthly,
// userWithAtLeastOneDocumentPerMonth,
// userWithAtLeastOneDocumentSignedPerMonth,
MONTHLY_USERS_SIGNED,
] = await Promise.all([
getUsersCount(),
getUsersWithSubscriptionsCount(),
getDocumentStats(),
getRecipientsStats(),
getSignerConversionMonthly(),
// getUserWithAtLeastOneDocumentPerMonth(),
// getUserWithAtLeastOneDocumentSignedPerMonth(),
getUserWithSignedDocumentMonthlyGrowth(),
]);
return {
usersCount,
usersWithSubscriptionsCount,
docStats,
recipientStats,
signerConversionMonthly,
MONTHLY_USERS_SIGNED,
};
}
export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
const { _ } = useLingui();
const {
usersCount,
usersWithSubscriptionsCount,
docStats,
recipientStats,
signerConversionMonthly,
MONTHLY_USERS_SIGNED,
} = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Instance Stats</Trans>
</h2>
<div className="mt-8 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<CardMetric icon={Users} title={_(msg`Total Users`)} value={usersCount} />
<CardMetric icon={File} title={_(msg`Total Documents`)} value={docStats.ALL} />
<CardMetric
icon={UserPlus}
title={_(msg`Active Subscriptions`)}
value={usersWithSubscriptionsCount}
/>
<CardMetric icon={FileCog} title={_(msg`App Version`)} value={`v${env('APP_VERSION')}`} />
</div>
<div className="mt-16 gap-8">
<div>
<h3 className="text-3xl font-semibold">
<Trans>Document metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric icon={FileEdit} title={_(msg`Drafted Documents`)} value={docStats.DRAFT} />
<CardMetric
icon={FileClock}
title={_(msg`Pending Documents`)}
value={docStats.PENDING}
/>
<CardMetric
icon={FileCheck}
title={_(msg`Completed Documents`)}
value={docStats.COMPLETED}
/>
</div>
</div>
<div>
<h3 className="text-3xl font-semibold">
<Trans>Recipients metrics</Trans>
</h3>
<div className="mb-8 mt-4 grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<CardMetric
icon={UserSquare2}
title={_(msg`Total Recipients`)}
value={recipientStats.TOTAL_RECIPIENTS}
/>
<CardMetric
icon={Mail}
title={_(msg`Documents Received`)}
value={recipientStats.SENT}
/>
<CardMetric
icon={MailOpen}
title={_(msg`Documents Viewed`)}
value={recipientStats.OPENED}
/>
<CardMetric
icon={PenTool}
title={_(msg`Signatures Collected`)}
value={recipientStats.SIGNED}
/>
</div>
</div>
</div>
<div className="mt-16">
<h3 className="text-3xl font-semibold">
<Trans>Charts</Trans>
</h3>
<div className="mt-5 grid grid-cols-2 gap-8">
<AdminStatsUsersWithDocumentsChart
data={MONTHLY_USERS_SIGNED}
title={_(msg`MAU (created document)`)}
tooltip={_(msg`Monthly Active Users: Users that created at least one Document`)}
/>
<AdminStatsUsersWithDocumentsChart
data={MONTHLY_USERS_SIGNED}
completed
title={_(msg`MAU (had document completed)`)}
tooltip={_(
msg`Monthly Active Users: Users that had at least one of their documents completed`,
)}
/>
<AdminStatsSignerConversionChart
title="Signers that Signed Up"
data={signerConversionMonthly}
/>
<AdminStatsSignerConversionChart
title={_(msg`Total Signers that Signed Up`)}
data={signerConversionMonthly}
cummulative
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import type { Route } from './+types/subscriptions';
export async function loader() {
const subscriptions = await findSubscriptions();
return { subscriptions };
}
export default function Subscriptions({ loaderData }: Route.ComponentProps) {
const { subscriptions } = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Manage subscriptions</Trans>
</h2>
<div className="mt-8">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>
<Trans>Status</Trans>
</TableHead>
<TableHead>
<Trans>Created At</Trans>
</TableHead>
<TableHead>
<Trans>Ends On</Trans>
</TableHead>
<TableHead>
<Trans>User ID</Trans>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((subscription, index) => (
<TableRow key={index}>
<TableCell>{subscription.id}</TableCell>
<TableCell>{subscription.status}</TableCell>
<TableCell>
{subscription.createdAt
? new Date(subscription.createdAt).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'N/A'}
</TableCell>
<TableCell>
{subscription.periodEnd
? new Date(subscription.periodEnd).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'N/A'}
</TableCell>
<TableCell>
<Link to={`/admin/users/${subscription.userId}`}>{subscription.userId}</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,163 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZAdminUpdateProfileMutationSchema } from '@documenso/trpc/server/admin-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';
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
import { MultiSelectRoleCombobox } from '../../../components/general/multiselect-role-combobox';
const ZUserFormSchema = ZAdminUpdateProfileMutationSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
export default function UserPage({ params }: { params: { id: number } }) {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { data: user } = trpc.profile.getUser.useQuery(
{
id: Number(params.id),
},
{
enabled: !!params.id,
},
);
const roles = user?.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
const form = useForm<TUserFormSchema>({
resolver: zodResolver(ZUserFormSchema),
values: {
name: user?.name ?? '',
email: user?.email ?? '',
roles: user?.roles ?? [],
},
});
const onSubmit = async ({ name, email, roles }: TUserFormSchema) => {
try {
await updateUserMutation({
id: Number(user?.id),
name,
email,
roles,
});
await revalidate();
toast({
title: _(msg`Profile updated`),
description: _(msg`Your profile has been updated.`),
duration: 5000,
});
} catch (e) {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating your profile.`),
variant: 'destructive',
});
}
};
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Manage {user?.name}'s profile</Trans>
</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roles"
render={({ field: { onChange } }) => (
<FormItem>
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">
<Trans>Roles</Trans>
</FormLabel>
<FormControl>
<MultiSelectRoleCombobox
listValues={roles}
onChange={(values: string[]) => onChange(values)}
/>
</FormControl>
<FormMessage />
</fieldset>
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update user</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
<hr className="my-4" />
<div className="flex flex-col items-center gap-4">
{user && <AdminUserDeleteDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { Trans } from '@lingui/react/macro';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { findUsers } from '@documenso/lib/server-only/user/get-all-users';
import { AdminDashboardUsersTable } from '~/components/tables/admin-dashboard-users-table';
import type { Route } from './+types/users._index';
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page')) || 1;
const perPage = Number(url.searchParams.get('perPage')) || 10;
const search = url.searchParams.get('search') || '';
const [{ users, totalPages }, individualPrices] = await Promise.all([
findUsers({ username: search, email: search, page, perPage }),
getPricesByPlan([STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY]).catch(() => []),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
return {
users,
totalPages,
individualPriceIds,
page,
perPage,
};
}
export default function AdminManageUsersPage({ loaderData }: Route.ComponentProps) {
const { users, totalPages, individualPriceIds, page, perPage } = loaderData;
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Manage users</Trans>
</h2>
<AdminDashboardUsersTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
</div>
);
}