feat: migrate nextjs to rr7

This commit is contained in:
David Nguyen
2025-01-02 15:33:37 +11:00
committed by Mythie
parent 9183f668d3
commit 75d7336763
1021 changed files with 60930 additions and 40839 deletions

View File

@ -0,0 +1,65 @@
import { Outlet, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { getLimits } from '@documenso/ee/server-only/limits/client';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { AppBanner } from '~/components/general/app-banner';
import { Header } from '~/components/general/app-header';
import { VerifyEmailBanner } from '~/components/general/verify-email-banner';
import type { Route } from './+types/_layout';
/**
* Don't revalidate (run the loader on sequential navigations)
*
* Update values via providers.
*/
export const shouldRevalidate = () => false;
export async function loader({ request }: Route.LoaderArgs) {
const requestHeaders = Object.fromEntries(request.headers.entries());
const session = await getOptionalSession(request);
if (!session.isAuthenticated) {
throw redirect('/signin');
}
const [limits, banner] = await Promise.all([
getLimits({ headers: requestHeaders }),
getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
),
]);
return {
banner,
limits,
};
}
export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, teams } = useSession();
const { banner, limits } = loaderData;
return (
<LimitsProvider initialValue={limits}>
<div id="portal-header"></div>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
{banner && <AppBanner banner={banner} />}
<Header user={user} teams={teams} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<Outlet />
</main>
</LimitsProvider>
);
}

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,122 @@
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 { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/_layout';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
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,167 @@
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 &&
recipient.signingStatus !== SigningStatus.REJECTED,
)}
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>
);
}

View File

@ -0,0 +1,263 @@
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentHistorySheet } from '~/components/general/document/document-history-sheet';
import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button';
import { DocumentPageViewDropdown } from '~/components/general/document/document-page-view-dropdown';
import { DocumentPageViewInformation } from '~/components/general/document/document-page-view-information';
import { DocumentPageViewRecentActivity } from '~/components/general/document/document-page-view-recent-activity';
import { DocumentPageViewRecipients } from '~/components/general/document/document-page-view-recipients';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/general/document/document-status';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/documents.$id._index';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
const document = await getDocumentById({
documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (document?.teamId && !team?.url) {
throw redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team?.currentTeamMember?.role;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (team && !isRecipient && document?.userId !== user.id) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
}
if (!document || !document.documentData || (team && !canAccessDocument)) {
throw redirect(documentRootPath);
}
if (team && !canAccessDocument) {
throw redirect(documentRootPath);
}
// Todo: Get full document instead?
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
teamId: team?.id,
userId: user.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
]);
const documentWithRecipients = {
...document,
recipients,
};
return superLoaderJson({
document: documentWithRecipients,
documentRootPath,
fields,
});
}
export default function DocumentPage() {
const loaderData = useSuperLoaderData<typeof loader>();
const { _ } = useLingui();
const { user } = useSession();
const { document, documentRootPath, fields } = loaderData;
const { recipients, documentData, documentMeta } = document;
// This was a feature flag. Leave to false since it's not ready.
const isDocumentHistoryEnabled = false;
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
{document.status === DocumentStatus.PENDING && (
<DocumentRecipientLinkCopyDialog recipients={recipients} />
)}
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<div className="flex flex-row justify-between truncate">
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
className="text-muted-foreground"
/>
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Trans>{recipients.length} Recipient(s)</Trans>
</span>
</StackAvatarsWithTooltip>
</div>
)}
{document.deletedAt && (
<Badge variant="destructive">
<Trans>Document deleted</Trans>
</Badge>
)}
</div>
</div>
{isDocumentHistoryEnabled && (
<div className="self-end">
<DocumentHistorySheet documentId={document.id} userId={user.id}>
<Button variant="outline">
<Clock9 className="mr-1.5 h-4 w-4" />
<Trans>Document history</Trans>
</Button>
</DocumentHistorySheet>
</div>
)}
</div>
<div className="mt-6 grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<PDFViewer document={document} key={documentData.id} documentData={documentData} />
</CardContent>
</Card>
{document.status === DocumentStatus.PENDING && (
<DocumentReadOnlyFields fields={fields} documentMeta={documentMeta || undefined} />
)}
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
{_(FRIENDLY_STATUS_MAP[document.status].labelExtended)}
</h3>
<DocumentPageViewDropdown document={document} />
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm">
{match(document.status)
.with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans>
))
.with(DocumentStatus.REJECTED, () => (
<Trans>This document has been rejected by a recipient</Trans>
))
.with(DocumentStatus.DRAFT, () => (
<Trans>This document is currently a draft and has not been sent</Trans>
))
.with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED',
);
return (
<Plural
value={pendingRecipients.length}
one="Waiting on 1 recipient"
other="Waiting on # recipients"
/>
);
})
.exhaustive()}
</p>
<div className="mt-4 border-t px-4 pt-4">
<DocumentPageViewButton document={document} />
</div>
</section>
{/* Document information section. */}
<DocumentPageViewInformation document={document} userId={user.id} />
{/* Recipients section. */}
<DocumentPageViewRecipients document={document} documentRootPath={documentRootPath} />
{/* Recent activity section. */}
<DocumentPageViewRecentActivity documentId={document.id} userId={user.id} />
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,138 @@
import { Plural, Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/documents.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
const document = await getDocumentWithDetailsById({
documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (document?.teamId && !team?.url) {
throw redirect(documentRootPath);
}
const documentVisibility = document?.visibility;
const currentTeamMemberRole = team?.currentTeamMember?.role;
const isRecipient = document?.recipients.find((recipient) => recipient.email === user.email);
let canAccessDocument = true;
if (!isRecipient && document?.userId !== user.id) {
canAccessDocument = match([documentVisibility, currentTeamMemberRole])
.with([DocumentVisibility.EVERYONE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.EVERYONE, TeamMemberRole.MEMBER], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.ADMIN], () => true)
.with([DocumentVisibility.MANAGER_AND_ABOVE, TeamMemberRole.MANAGER], () => true)
.with([DocumentVisibility.ADMIN, TeamMemberRole.ADMIN], () => true)
.otherwise(() => false);
}
if (!document) {
throw redirect(documentRootPath);
}
if (team && !canAccessDocument) {
throw redirect(documentRootPath);
}
if (isDocumentCompleted(document.status)) {
throw redirect(`${documentRootPath}/${documentId}`);
}
const isDocumentEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return superLoaderJson({
document,
documentRootPath,
isDocumentEnterprise,
});
}
export default function DocumentEditPage() {
const { document, documentRootPath, isDocumentEnterprise } = useSuperLoaderData<typeof loader>();
const { recipients } = document;
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Documents</Trans>
</Link>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatus inheritColor status={document.status} className="text-muted-foreground" />
{recipients.length > 0 && (
<div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" />
<StackAvatarsWithTooltip
recipients={recipients}
documentStatus={document.status}
position="bottom"
>
<span>
<Plural one="1 Recipient" other="# Recipients" value={recipients.length} />
</span>
</StackAvatarsWithTooltip>
</div>
)}
</div>
<DocumentEditForm
className="mt-6"
initialDocument={document}
documentRootPath={documentRootPath}
isDocumentEnterprise={isDocumentEnterprise}
/>
</div>
);
}

View File

@ -0,0 +1,188 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Card } from '@documenso/ui/primitives/card';
import { DocumentAuditLogDownloadButton } from '~/components/general/document/document-audit-log-download-button';
import { DocumentCertificateDownloadButton } from '~/components/general/document/document-certificate-download-button';
import {
DocumentStatus as DocumentStatusComponent,
FRIENDLY_STATUS_MAP,
} from '~/components/general/document/document-status';
import { DocumentLogsTable } from '~/components/tables/document-logs-table';
import type { Route } from './+types/documents.$id.logs';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
if (!documentId || Number.isNaN(documentId)) {
throw redirect(documentRootPath);
}
// Todo: Get full document instead?
const [document, recipients] = await Promise.all([
getDocumentById({
documentId,
userId: user.id,
teamId: team?.id,
}).catch(() => null),
getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
]);
if (!document || !document.documentData) {
throw redirect(documentRootPath);
}
return {
document,
documentRootPath,
recipients,
};
}
export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps) {
const { document, documentRootPath, recipients } = loaderData;
const { _, i18n } = useLingui();
const documentInformation: { description: MessageDescriptor; value: string }[] = [
{
description: msg`Document title`,
value: document.title,
},
{
description: msg`Document ID`,
value: document.id.toString(),
},
{
description: msg`Document status`,
value: _(FRIENDLY_STATUS_MAP[document.status].label),
},
{
description: msg`Created by`,
value: document.user.name
? `${document.user.name} (${document.user.email})`
: document.user.email,
},
{
description: msg`Date created`,
value: DateTime.fromJSDate(document.createdAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
},
{
description: msg`Last updated`,
value: DateTime.fromJSDate(document.updatedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS),
},
{
description: msg`Time zone`,
value: document.documentMeta?.timezone ?? 'N/A',
},
];
const formatRecipientText = (recipient: Recipient) => {
let text = recipient.email;
if (recipient.name) {
text = `${recipient.name} (${recipient.email})`;
}
return `[${recipient.role}] ${text}`;
};
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link
to={`${documentRootPath}/${document.id}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Document</Trans>
</Link>
<div className="flex flex-col">
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
</div>
<div className="mt-1 flex flex-col justify-between sm:flex-row">
<div className="mt-2.5 flex items-center gap-x-6">
<DocumentStatusComponent
inheritColor
status={document.status}
className="text-muted-foreground"
/>
</div>
<div className="mt-4 flex w-full flex-row sm:mt-0 sm:w-auto sm:self-end">
<DocumentCertificateDownloadButton
className="mr-2"
documentId={document.id}
documentStatus={document.status}
/>
<DocumentAuditLogDownloadButton documentId={document.id} />
</div>
</div>
</div>
<section className="mt-6">
<Card className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2" degrees={45} gradient>
{documentInformation.map((info, i) => (
<div className="text-foreground text-sm" key={i}>
<h3 className="font-semibold">{_(info.description)}</h3>
<p className="text-muted-foreground truncate">{info.value}</p>
</div>
))}
<div className="text-foreground text-sm">
<h3 className="font-semibold">Recipients</h3>
<ul className="text-muted-foreground list-inside list-disc">
{recipients.map((recipient) => (
<li key={`recipient-${recipient.id}`}>
<span className="-ml-2">{formatRecipientText(recipient)}</span>
</li>
))}
</ul>
</div>
</Card>
</section>
<section className="mt-6">
<DocumentLogsTable documentId={document.id} />
</section>
</div>
);
}

View File

@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { DocumentUploadDropzone } from '~/components/general/document/document-upload';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Documents');
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
export default function DocumentsPage() {
const [searchParams] = useSearchParams();
const team = useOptionalCurrentTeam();
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
);
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocumentsInternal.useQuery(
{
...findDocumentSearchParams,
},
);
// Refetch the documents when the team URL changes.
useEffect(() => {
void refetch();
}, [team?.url]);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
if (params.has('page')) {
params.delete('page');
}
return `${formatDocumentsPath(team?.url)}?${params.toString()}`;
};
useEffect(() => {
if (data?.stats) {
setStats(data.stats);
}
}, [data?.stats]);
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<DocumentUploadDropzone />
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="text-4xl font-semibold">
<Trans>Documents</Trans>
</h1>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
/>
) : (
<DocumentsTable data={data} isLoading={isLoading} isLoadingError={isLoadingError} />
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { redirect } from 'react-router';
export function loader() {
throw redirect('/settings/profile');
}

View File

@ -0,0 +1,29 @@
import { Trans } from '@lingui/react/macro';
import { Outlet } from 'react-router';
import { SettingsDesktopNav } from '~/components/general/settings-nav-desktop';
import { SettingsMobileNav } from '~/components/general/settings-nav-mobile';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Settings');
}
export default function SettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">
<Trans>Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<SettingsDesktopNav className="hidden md:col-span-3 md:flex" />
<SettingsMobileNav className="col-span-12 mb-8 md:hidden" />
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,157 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { type Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { BillingPlans } from '~/components/general/billing-plans';
import { BillingPortalButton } from '~/components/general/billing-portal-button';
import { appMetaTags } from '~/utils/meta';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/billing';
export function meta() {
return appMetaTags('Billing');
}
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
// Redirect if subscriptions are not enabled.
if (!IS_BILLING_ENABLED()) {
throw redirect('/settings/profile');
}
if (!user.customerId) {
await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plans: [STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.PLATFORM] }),
getPrimaryAccountPlanPrices(),
]);
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
primaryAccountPlanPriceIds.includes(priceId),
);
const subscription =
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
primaryAccountPlanSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
() => null,
);
}
const isMissingOrInactiveOrFreePlan =
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
return superLoaderJson({
prices,
subscription,
subscriptionProductName: subscriptionProduct?.name,
isMissingOrInactiveOrFreePlan,
});
}
export default function TeamsSettingBillingPage() {
const { prices, subscription, subscriptionProductName, isMissingOrInactiveOrFreePlan } =
useSuperLoaderData<typeof loader>();
const { i18n } = useLingui();
return (
<div>
<div className="flex flex-row items-end justify-between">
<div>
<h3 className="text-2xl font-semibold">
<Trans>Billing</Trans>
</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
<Trans>
You are currently on the <span className="font-semibold">Free Plan</span>.
</Trans>
</p>
)}
{/* Todo: Translation */}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProductName ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProductName}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{subscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<span className="font-semibold">
{i18n.date(subscription.periodEnd)}.
</span>
</span>
) : (
<span>
automatically renew on{' '}
<span className="font-semibold">
{i18n.date(subscription.periodEnd)}.
</span>
</span>
)}
</span>
)}
</p>
))
.with('PAST_DUE', () => (
<p>
<Trans>
Your current plan is past due. Please update your payment information.
</Trans>
</p>
))
.otherwise(() => null)}
</div>
</div>
{isMissingOrInactiveOrFreePlan && (
<BillingPortalButton>
<Trans>Manage billing</Trans>
</BillingPortalButton>
)}
</div>
<hr className="my-4" />
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
</div>
);
}

View File

@ -0,0 +1,32 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { AccountDeleteDialog } from '~/components/dialogs/account-delete-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { ProfileForm } from '~/components/forms/profile';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Profile');
}
export default function SettingsProfile() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Profile`)}
subtitle={_(msg`Here you can edit your personal details.`)}
/>
<AvatarImageForm className="mb-8 max-w-xl" />
<ProfileForm className="mb-8 max-w-xl" />
<hr className="my-4 max-w-xl" />
<AccountDeleteDialog className="max-w-xl" />
</div>
);
}

View File

@ -0,0 +1,237 @@
import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
import { trpc } from '@documenso/trpc/react';
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Switch } from '@documenso/ui/primitives/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ManagePublicTemplateDialog } from '~/components/dialogs/public-profile-template-manage-dialog';
import type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
import { PublicProfileForm } from '~/components/forms/public-profile-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsPublicProfileTemplatesTable } from '~/components/tables/settings-public-profile-templates-table';
import { useOptionalCurrentTeam } from '~/providers/team';
import type { Route } from './+types/public-profile';
type DirectTemplate = FindTemplateRow & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
};
const userProfileText = {
settingsTitle: msg`Public Profile`,
settingsSubtitle: msg`You can choose to enable or disable your profile for public view.`,
templatesTitle: msg`My templates`,
templatesSubtitle: msg`Show templates in your public profile for your audience to sign and get started quickly`,
};
const teamProfileText = {
settingsTitle: msg`Team Public Profile`,
settingsSubtitle: msg`You can choose to enable or disable your team profile for public view.`,
templatesTitle: msg`Team templates`,
templatesSubtitle: msg`Show templates in your team public profile for your audience to sign and get started quickly`,
};
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const { profile } = await getUserPublicProfile({
userId: user.id,
});
return { profile };
}
export default function PublicProfilePage({ loaderData }: Route.ComponentProps) {
const { profile } = loaderData;
const { _ } = useLingui();
const { toast } = useToast();
const { user, refreshSession } = useSession();
const team = useOptionalCurrentTeam();
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { data } = trpc.template.findTemplates.useQuery({
perPage: 100,
});
const { mutateAsync: updateUserProfile, isPending: isUpdatingUserProfile } =
trpc.profile.updatePublicProfile.useMutation();
const { mutateAsync: updateTeamProfile, isPending: isUpdatingTeamProfile } =
trpc.team.updateTeamPublicProfile.useMutation();
const isUpdating = isUpdatingUserProfile || isUpdatingTeamProfile;
const profileText = team ? teamProfileText : userProfileText;
const enabledPrivateDirectTemplates = useMemo(
() =>
(data?.data ?? []).filter(
(template): template is DirectTemplate =>
template.directLink?.enabled === true && template.type !== TemplateType.PUBLIC,
),
[data],
);
const onProfileUpdate = async (data: TPublicProfileFormSchema) => {
if (team) {
await updateTeamProfile({
teamId: team.id,
...data,
});
} else {
await updateUserProfile(data);
// Need to refresh session because we're editing the user's profile.
await refreshSession();
}
if (data.enabled === undefined && !isPublicProfileVisible) {
setIsTooltipOpen(true);
}
};
const togglePublicProfileVisibility = async (isVisible: boolean) => {
setIsTooltipOpen(false);
if (isUpdating) {
return;
}
if (isVisible && !user.url) {
toast({
title: _(msg`You must set a profile URL before enabling your public profile.`),
variant: 'destructive',
});
return;
}
setIsPublicProfileVisible(isVisible);
try {
await onProfileUpdate({
enabled: isVisible,
});
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to set your public profile to public. Please try again.`),
variant: 'destructive',
});
setIsPublicProfileVisible(!isVisible);
}
};
useEffect(() => {
setIsPublicProfileVisible(profile.enabled);
}, [profile.enabled]);
return (
<div className="max-w-2xl">
<SettingsHeader
title={_(profileText.settingsTitle)}
subtitle={_(profileText.settingsSubtitle)}
>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<div
className={cn(
'text-muted-foreground/50 flex flex-row items-center justify-center space-x-2 text-xs',
{
'[&>*:first-child]:text-muted-foreground': !isPublicProfileVisible,
'[&>*:last-child]:text-muted-foreground': isPublicProfileVisible,
},
)}
>
<span>
<Trans>Hide</Trans>
</span>
<Switch
disabled={isUpdating}
checked={isPublicProfileVisible}
onCheckedChange={togglePublicProfileVisibility}
/>
<span>
<Trans>Show</Trans>
</span>
</div>
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-[40ch] space-y-2 py-2">
{isPublicProfileVisible ? (
<>
<p>
<Trans>
Profile is currently <strong>visible</strong>.
</Trans>
</p>
<p>
<Trans>Toggle the switch to hide your profile from the public.</Trans>
</p>
</>
) : (
<>
<p>
<Trans>
Profile is currently <strong>hidden</strong>.
</Trans>
</p>
<p>
<Trans>Toggle the switch to show your profile to the public.</Trans>
</p>
</>
)}
</TooltipContent>
</Tooltip>
</SettingsHeader>
<PublicProfileForm
profileUrl={team ? team.url : user.url}
teamUrl={team?.url}
profile={profile}
onProfileUpdate={onProfileUpdate}
/>
<div className="mt-4">
<SettingsHeader
title={_(profileText.templatesTitle)}
subtitle={_(profileText.templatesSubtitle)}
hideDivider={true}
className="mt-8 [&>*>h3]:text-base"
>
<ManagePublicTemplateDialog
directTemplates={enabledPrivateDirectTemplates}
trigger={
<Button variant="outline">
<Trans>Link template</Trans>
</Button>
}
/>
</SettingsHeader>
<div className="mt-6">
<SettingsPublicProfileTemplatesTable />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,176 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { prisma } from '@documenso/prisma';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { PasswordForm } from '~/components/forms/password';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/security._index';
export function meta() {
return appMetaTags('Security');
}
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
// Todo: Use providers instead after RR7 migration.
// const accounts = await prisma.account.findMany({
// where: {
// userId: user.id,
// },
// select: {
// provider: true,
// },
// });
// const providers = accounts.map((account) => account.provider);
// let hasEmailPasswordAccount = providers.includes('DOCUMENSO');
const hasEmailPasswordAccount: boolean = await prisma.user
.count({
where: {
id: user.id,
password: {
not: null,
},
},
})
.then((value) => value > 0);
return {
// providers,
hasEmailPasswordAccount,
};
}
export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
const { hasEmailPasswordAccount } = loaderData;
const { _ } = useLingui();
const { user } = useSession();
return (
<div>
<SettingsHeader
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{hasEmailPasswordAccount && (
<>
<PasswordForm user={user} />
<hr className="border-border/50 mt-6" />
</>
)}
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Two factor authentication</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
{hasEmailPasswordAccount ? (
<Trans>
Add an authenticator to serve as a secondary authentication method when signing in,
or when signing documents.
</Trans>
) : (
<Trans>
Add an authenticator to serve as a secondary authentication method for signing
documents.
</Trans>
)}
</AlertDescription>
</div>
{user.twoFactorEnabled ? (
<DisableAuthenticatorAppDialog />
) : (
<EnableAuthenticatorAppDialog />
)}
</Alert>
{user.twoFactorEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Recovery codes</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
<Trans>
Two factor authentication recovery codes are used to access your account in the
event that you lose access to your authenticator app.
</Trans>
</AlertDescription>
</div>
<ViewRecoveryCodesDialog />
</Alert>
)}
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Passkeys</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
<Trans>
Allows authenticating using biometrics, password managers, hardware keys, etc.
</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/passkeys">
<Trans>Manage passkeys</Trans>
</Link>
</Button>
</Alert>
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 mr-4 sm:mb-0">
<AlertTitle>
<Trans>Recent activity</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>View all recent security activity related to your account.</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link to="/settings/security/activity">
<Trans>View activity</Trans>
</Link>
</Button>
</Alert>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsSecurityActivityTable } from '~/components/tables/settings-security-activity-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Security activity');
}
export default function SettingsSecurityActivity() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Security activity`)}
subtitle={_(msg`View all security activity related to your account.`)}
hideDivider={true}
/>
<div className="mt-4">
<SettingsSecurityActivityTable />
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { PasskeyCreateDialog } from '~/components/dialogs/passkey-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { SettingsSecurityPasskeyTable } from '~/components/tables/settings-security-passkey-table';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Manage passkeys');
}
export default function SettingsPasskeys() {
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Passkeys`)}
subtitle={_(msg`Manage your passkeys.`)}
hideDivider={true}
>
<PasskeyCreateDialog />
</SettingsHeader>
<div className="mt-4">
<SettingsSecurityPasskeyTable />
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { AnimatePresence } from 'framer-motion';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailUsage } from '~/components/general/teams/team-email-usage';
import { TeamInvitations } from '~/components/general/teams/team-invitations';
import { UserSettingsTeamsPageDataTable } from '~/components/tables/user-settings-teams-page-table';
export default function TeamsSettingsPage() {
const { _ } = useLingui();
const { data: teamEmail } = trpc.team.getTeamEmailByEmail.useQuery();
return (
<div>
<SettingsHeader
title={_(msg`Teams`)}
subtitle={_(msg`Manage all teams you are currently associated with.`)}
>
<TeamCreateDialog />
</SettingsHeader>
<UserSettingsTeamsPageDataTable />
<div className="mt-8 space-y-8">
<AnimatePresence>
{teamEmail && (
<AnimateGenericFadeInOut>
<TeamEmailUsage teamEmail={teamEmail} />
</AnimateGenericFadeInOut>
)}
</AnimatePresence>
<TeamInvitations />
</div>
</div>
);
}

View File

@ -0,0 +1,116 @@
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import { DateTime } from 'luxon';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import TokenDeleteDialog from '~/components/dialogs/token-delete-dialog';
import { ApiTokenForm } from '~/components/forms/token';
import { SettingsHeader } from '~/components/general/settings-header';
import { useOptionalCurrentTeam } from '~/providers/team';
export default function ApiTokensPage() {
const { i18n } = useLingui();
const { data: tokens } = trpc.apiToken.getTokens.useQuery();
const team = useOptionalCurrentTeam();
return (
<div>
<SettingsHeader
title={<Trans>API Tokens</Trans>}
subtitle={
<Trans>
On this page, you can create and manage API tokens. See our{' '}
<a
className="text-primary underline"
href={'https://docs.documenso.com/developers/public-api'}
target="_blank"
>
Documentation
</a>{' '}
for more information.
</Trans>
}
/>
{team && team?.currentTeamMember.role !== TeamMemberRole.ADMIN ? (
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="warning"
>
<div>
<AlertTitle>
<Trans>Unauthorized</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>You need to be an admin to manage API tokens.</Trans>
</AlertDescription>
</div>
</Alert>
) : (
<>
<ApiTokenForm className="max-w-xl" tokens={tokens} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">
<Trans>Your existing tokens</Trans>
</h4>
{tokens && tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>Your tokens will be shown here once you create them.</Trans>
</p>
</div>
)}
{tokens && tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>
Created on {i18n.date(token.createdAt, DateTime.DATETIME_FULL)}
</Trans>
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>
Expires on {i18n.date(token.expires, DateTime.DATETIME_FULL)}
</Trans>
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
<Trans>Token doesn't have an expiration date</Trans>
</p>
)}
</div>
<div>
<TokenDeleteDialog token={token}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</TokenDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,212 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useParams, useRevalidator } from 'react-router';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export default function WebhookPage() {
const params = useParams();
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const webhookId = params.id || '';
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
{
id: webhookId,
},
{ enabled: !!webhookId },
);
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
const form = useForm<TEditWebhookFormSchema>({
resolver: zodResolver(ZEditWebhookFormSchema),
values: {
webhookUrl: webhook?.webhookUrl ?? '',
eventTriggers: webhook?.eventTriggers ?? [],
secret: webhook?.secret ?? '',
enabled: webhook?.enabled ?? true,
},
});
const onSubmit = async (data: TEditWebhookFormSchema) => {
try {
await updateWebhook({
id: webhookId,
...data,
});
toast({
title: _(msg`Webhook updated`),
description: _(msg`The webhook has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`Failed to update webhook`),
description: _(
msg`We encountered an error while updating the webhook. Please try again later.`,
),
variant: 'destructive',
});
}
};
return (
<div>
<SettingsHeader
title={_(msg`Edit webhook`)}
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
/>
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>
<Trans>Webhook URL</Trans>
</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>The URL for Documenso to send webhook events to.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
<Trans> The events that will trigger a webhook to be sent to your URL.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Secret</Trans>
</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
<Trans>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update webhook</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,106 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
export default function WebhookPage() {
const { _, i18n } = useLingui();
const { data: webhooks, isLoading } = trpc.webhook.getWebhooks.useQuery();
return (
<div>
<SettingsHeader
title={_(msg`Webhooks`)}
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
>
<WebhookCreateDialog />
</SettingsHeader>
{isLoading && (
<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>
)}
{webhooks && webhooks.length === 0 && (
// TODO: Perhaps add some illustrations here to make the page more engaging
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>
You have no webhooks yet. Your webhooks will be shown here once you create them.
</Trans>
</p>
</div>
)}
{webhooks && webhooks.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{webhooks?.map((webhook) => (
<div
key={webhook.id}
className={cn(
'border-border rounded-lg border p-4',
!webhook.enabled && 'bg-muted/40',
)}
>
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="truncate font-mono text-xs">{webhook.id}</div>
<div className="mt-1.5 flex items-center gap-4">
<h5
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
title={webhook.webhookUrl}
>
{webhook.webhookUrl}
</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</Trans>
</p>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
<Button asChild variant="outline">
<Link to={`/settings/webhooks/${webhook.id}`}>
<Trans>Edit</Trans>
</Link>
</Button>
<WebhookDeleteDialog webhook={webhook}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</WebhookDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,9 @@
import { redirect } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Route } from './+types/_index';
export function loader({ params }: Route.LoaderArgs) {
throw redirect(formatDocumentsPath(params.teamUrl));
}

View File

@ -0,0 +1,104 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { SubscriptionStatus } from '@prisma/client';
import { Link, Outlet } from 'react-router';
import { TEAM_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { TrpcProvider } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { PortalComponent } from '~/components/general/portal';
import { TeamLayoutBillingBanner } from '~/components/general/teams/team-layout-billing-banner';
import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
export default function Layout({ params }: Route.ComponentProps) {
const { teams } = useSession();
const currentTeam = teams.find((team) => team.url === params.teamUrl);
const limits = useMemo(() => {
if (!currentTeam) {
return undefined;
}
if (
currentTeam?.subscription &&
currentTeam.subscription.status === SubscriptionStatus.INACTIVE
) {
return {
quota: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
remaining: {
documents: 0,
recipients: 0,
directTemplates: 0,
},
};
}
return {
quota: TEAM_PLAN_LIMITS,
remaining: TEAM_PLAN_LIMITS,
};
}, [currentTeam?.subscription, currentTeam?.id]);
if (!currentTeam) {
return (
<GenericErrorLayout
errorCode={404}
errorCodeMap={{
404: {
heading: msg`Team not found`,
subHeading: msg`404 Team not found`,
message: msg`The team you are looking for may have been removed, renamed or may have never
existed.`,
},
}}
primaryButton={
<Button asChild>
<Link to="/settings/teams">
<Trans>View teams</Trans>
</Link>
</Button>
}
></GenericErrorLayout>
);
}
const trpcHeaders = {
'x-team-Id': currentTeam.id.toString(),
};
return (
<TeamProvider team={currentTeam}>
<LimitsProvider initialValue={limits} teamId={currentTeam.id}>
<TrpcProvider headers={trpcHeaders}>
{currentTeam?.subscription &&
currentTeam.subscription.status !== SubscriptionStatus.ACTIVE && (
<PortalComponent target="portal-header">
<TeamLayoutBillingBanner
subscriptionStatus={currentTeam.subscription.status}
teamId={currentTeam.id}
userRole={currentTeam.currentTeamMember.role}
/>
</PortalComponent>
)}
<main className="mt-8 pb-8 md:mt-12 md:pb-12">
<Outlet />
</main>
</TrpcProvider>
</LimitsProvider>
</TeamProvider>
);
}

View File

@ -0,0 +1,5 @@
import DocumentPage, { loader } from '~/routes/_authenticated+/documents.$id._index';
export { loader };
export default DocumentPage;

View File

@ -0,0 +1,5 @@
import DocumentEditPage, { loader } from '~/routes/_authenticated+/documents.$id.edit';
export { loader };
export default DocumentEditPage;

View File

@ -0,0 +1,5 @@
import DocumentLogsPage, { loader } from '~/routes/_authenticated+/documents.$id.logs';
export { loader };
export default DocumentLogsPage;

View File

@ -0,0 +1,5 @@
import DocumentsPage, { meta } from '~/routes/_authenticated+/documents._index';
export { meta };
export default DocumentsPage;

View File

@ -0,0 +1,213 @@
import { Trans } from '@lingui/react/macro';
import { CheckCircle2, Clock } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { TeamDeleteDialog } from '~/components/dialogs/team-delete-dialog';
import { TeamEmailAddDialog } from '~/components/dialogs/team-email-add-dialog';
import { TeamTransferDialog } from '~/components/dialogs/team-transfer-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamUpdateForm } from '~/components/forms/team-update-form';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamEmailDropdown } from '~/components/general/teams/team-email-dropdown';
import { TeamTransferStatus } from '~/components/general/teams/team-transfer-status';
import type { Route } from './+types/settings._index';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({
userId: user.id,
teamUrl: params.teamUrl,
});
return {
team,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { team } = loaderData;
const { user } = useSession();
const isTransferVerificationExpired =
!team.transferVerification || isTokenExpired(team.transferVerification.expiresAt);
return (
<div>
<SettingsHeader title="General settings" subtitle="Here you can edit your team's details." />
<TeamTransferStatus
className="mb-4"
currentUserTeamRole={team.currentTeamMember.role}
teamId={team.id}
transferVerification={team.transferVerification}
/>
<AvatarImageForm className="mb-8" />
<TeamUpdateForm teamId={team.id} teamName={team.name} teamUrl={team.url} />
<section className="mt-6 space-y-6">
{(team.teamEmail || team.emailVerification) && (
<Alert className="p-6" variant="neutral">
<AlertTitle>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
You can view documents associated with this email and use this identity when sending
documents.
</Trans>
</AlertDescription>
<hr className="border-border/50 mt-2" />
<div className="flex flex-row items-center justify-between pt-4">
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials(
(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">
{match({
teamEmail: team.teamEmail,
emailVerification: team.emailVerification,
})
.with({ teamEmail: P.not(null) }, () => (
<>
<CheckCircle2 className="mr-1.5 text-green-500 dark:text-green-300" />
<Trans>Active</Trans>
</>
))
.with(
{
emailVerification: P.when(
(emailVerification) =>
emailVerification && emailVerification?.expiresAt < new Date(),
),
},
() => (
<>
<Clock className="mr-1.5 text-yellow-500 dark:text-yellow-200" />
<Trans>Expired</Trans>
</>
),
)
.with({ emailVerification: P.not(null) }, () => (
<>
<Clock className="mr-1.5 text-blue-600 dark:text-blue-300" />
<Trans>Awaiting email confirmation</Trans>
</>
))
.otherwise(() => null)}
</div>
<TeamEmailDropdown team={team} />
</div>
</div>
</Alert>
)}
{!team.teamEmail && !team.emailVerification && (
<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>
<Trans>Team email</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<ul className="text-muted-foreground mt-0.5 list-inside list-disc text-sm">
{/* Feature not available yet. */}
{/* <li>Display this name and email when sending documents</li> */}
{/* <li>View documents associated with this email</li> */}
<span>
<Trans>View documents associated with this email</Trans>
</span>
</ul>
</AlertDescription>
</div>
<TeamEmailAddDialog teamId={team.id} />
</Alert>
)}
{team.ownerUserId === user.id && (
<>
{isTransferVerificationExpired && (
<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>
<Trans>Transfer team</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>Transfer the ownership of the team to another team member.</Trans>
</AlertDescription>
</div>
<TeamTransferDialog
ownerUserId={team.ownerUserId}
teamId={team.id}
teamName={team.name}
/>
</Alert>
)}
<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>
<Trans>Delete team</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
This team, and any associated data excluding billing invoices will be
permanently deleted.
</Trans>
</AlertDescription>
</div>
<TeamDeleteDialog teamId={team.id} teamName={team.name} />
</Alert>
</>
)}
</section>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { Trans } from '@lingui/react/macro';
import { Outlet, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings-nav-desktop';
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/settings._layout';
export function meta() {
return appMetaTags('Team Settings');
}
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw redirect(`/t/${params.teamUrl}`);
}
}
export async function clientLoader() {
// Do nothing, we only want the loader to run on SSR.
}
export default function TeamsSettingsLayout() {
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<h1 className="text-4xl font-semibold">
<Trans>Team Settings</Trans>
</h1>
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
<TeamSettingsNavDesktop className="hidden md:col-span-3 md:flex" />
<TeamSettingsNavMobile className="col-span-12 mb-8 md:hidden" />
<div className="col-span-12 md:col-span-9">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,109 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamBillingPortalButton } from '~/components/general/teams/team-billing-portal-button';
import { TeamSettingsBillingInvoicesTable } from '~/components/tables/team-settings-billing-invoices-table';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/settings.billing';
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
let teamSubscription: Stripe.Subscription | null = null;
if (team.subscription) {
teamSubscription = await stripe.subscriptions.retrieve(team.subscription.planId);
}
return superLoaderJson({
team,
teamSubscription,
});
}
export default function TeamsSettingBillingPage() {
const { _ } = useLingui();
const { team, teamSubscription } = useSuperLoaderData<typeof loader>();
const canManageBilling = canExecuteTeamAction('MANAGE_BILLING', team.currentTeamMember.role);
const formatTeamSubscriptionDetails = (subscription: Stripe.Subscription | null) => {
if (!subscription) {
return <Trans>No payment required</Trans>;
}
const numberOfSeats = subscription.items.data[0].quantity ?? 0;
const formattedDate = DateTime.fromSeconds(subscription.current_period_end).toFormat(
'LLL dd, yyyy',
);
const subscriptionInterval = match(subscription?.items.data[0].plan.interval)
.with('year', () => _(msg`Yearly`))
.with('month', () => _(msg`Monthly`))
.otherwise(() => _(msg`Unknown`));
return (
<span>
<Plural value={numberOfSeats} one="# member" other="# members" />
{' • '}
<span>{subscriptionInterval}</span>
{' • '}
<Trans>Renews: {formattedDate}</Trans>
</span>
);
};
return (
<div>
<SettingsHeader
title={_(msg`Billing`)}
subtitle={_(msg`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">
{formatTeamSubscriptionDetails(teamSubscription)}
</p>
</div>
{teamSubscription && (
<div
title={
canManageBilling
? _(msg`Manage team subscription.`)
: _(msg`You must be an admin of this team to manage billing.`)
}
>
<TeamBillingPortalButton teamId={team.id} />
</div>
)}
</CardContent>
</Card>
<section className="mt-6">
<TeamSettingsBillingInvoicesTable teamId={team.id} />
</section>
</div>
);
}

View File

@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Link, useLocation, useSearchParams } from 'react-router';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { TeamMemberInviteDialog } from '~/components/dialogs/team-member-invite-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { TeamSettingsMemberInvitesTable } from '~/components/tables/team-settings-member-invites-table';
import { TeamSettingsMembersDataTable } from '~/components/tables/team-settings-members-table';
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
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(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
title={_(msg`Members`)}
subtitle={_(msg`Manage the members or invite new members.`)}
>
<TeamMemberInviteDialog />
</SettingsHeader>
<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={_(msg`Search`)}
/>
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
<TabsList>
<TabsTrigger className="min-w-[60px]" value="members" asChild>
<Link to={pathname ?? '/'}>
<Trans>Active</Trans>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="invites" asChild>
<Link to={`${pathname}?tab=invites`}>
<Trans>Pending</Trans>
</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{currentTab === 'invites' ? (
<TeamSettingsMemberInvitesTable key="invites" />
) : (
<TeamSettingsMembersDataTable key="members" />
)}
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { TeamBrandingPreferencesForm } from '~/components/forms/team-branding-preferences-form';
import { TeamDocumentPreferencesForm } from '~/components/forms/team-document-preferences-form';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/settings.preferences';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
return {
team,
};
}
export default function TeamsSettingsPage({ loaderData }: Route.ComponentProps) {
const { team } = loaderData;
const { _ } = useLingui();
return (
<div>
<SettingsHeader
title={_(msg`Team Preferences`)}
subtitle={_(msg`Here you can set preferences and defaults for your team.`)}
/>
<section>
<TeamDocumentPreferencesForm team={team} settings={team.teamGlobalSettings} />
</section>
<SettingsHeader
title={_(msg`Branding Preferences`)}
subtitle={_(msg`Here you can set preferences and defaults for branding.`)}
className="mt-8"
/>
<section>
<TeamBrandingPreferencesForm team={team} settings={team.teamGlobalSettings} />
</section>
</div>
);
}

View File

@ -0,0 +1,29 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile';
import type { Route } from './+types/settings.public-profile';
// Todo: This can be optimized.
export async function loader({ request, params }: Route.LoaderArgs) {
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
const { profile } = await getTeamPublicProfile({
userId: session.user.id,
teamId: team.id,
});
return {
profile,
};
}
// Todo: Test that the profile shows up correctly for teams.
export default PublicProfilePage;

View File

@ -0,0 +1,3 @@
import ApiTokensPage from '~/routes/_authenticated+/settings+/tokens';
export default ApiTokensPage;

View File

@ -0,0 +1,211 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookMutationSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/general/settings-header';
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
import type { Route } from './+types/settings.webhooks.$id';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
type TEditWebhookFormSchema = z.infer<typeof ZEditWebhookFormSchema>;
export default function WebhookPage({ params }: Route.ComponentProps) {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const team = useCurrentTeam();
const { data: webhook, isLoading } = trpc.webhook.getWebhookById.useQuery(
{
id: params.id,
teamId: team.id,
},
{ enabled: !!params.id && !!team.id },
);
const { mutateAsync: updateWebhook } = trpc.webhook.editWebhook.useMutation();
const form = useForm<TEditWebhookFormSchema>({
resolver: zodResolver(ZEditWebhookFormSchema),
values: {
webhookUrl: webhook?.webhookUrl ?? '',
eventTriggers: webhook?.eventTriggers ?? [],
secret: webhook?.secret ?? '',
enabled: webhook?.enabled ?? true,
},
});
const onSubmit = async (data: TEditWebhookFormSchema) => {
try {
await updateWebhook({
id: params.id,
teamId: team.id,
...data,
});
toast({
title: _(msg`Webhook updated`),
description: _(msg`The webhook has been updated successfully.`),
duration: 5000,
});
await revalidate();
} catch (err) {
toast({
title: _(msg`Failed to update webhook`),
description: _(
msg`We encountered an error while updating the webhook. Please try again later.`,
),
variant: 'destructive',
});
}
};
return (
<div>
<SettingsHeader
title={_(msg`Edit webhook`)}
subtitle={_(msg`On this page, you can edit the webhook and its settings.`)}
/>
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full max-w-xl flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel required>Webhook URL</FormLabel>
<FormControl>
<Input className="bg-background" {...field} />
</FormControl>
<FormDescription>
<Trans>The URL for Documenso to send webhook events to.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Enabled</Trans>
</FormLabel>
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="eventTriggers"
render={({ field: { onChange, value } }) => (
<FormItem className="flex flex-col gap-2">
<FormLabel required>
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);
}}
/>
</FormControl>
<FormDescription>
<Trans>The events that will trigger a webhook to be sent to your URL.</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="secret"
render={({ field }) => (
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
<Trans>
A secret that will be sent to your URL so you can verify that the request has
been sent by Documenso.
</Trans>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update webhook</Trans>
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,111 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
import { useCurrentTeam } from '~/providers/team';
export default function WebhookPage() {
const { _, i18n } = useLingui();
const team = useCurrentTeam();
const { data: webhooks, isLoading } = trpc.webhook.getTeamWebhooks.useQuery({
teamId: team.id,
});
return (
<div>
<SettingsHeader
title={_(msg`Webhooks`)}
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
>
<WebhookCreateDialog />
</SettingsHeader>
{isLoading && (
<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>
)}
{webhooks && webhooks.length === 0 && (
// TODO: Perhaps add some illustrations here to make the page more engaging
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
<Trans>
You have no webhooks yet. Your webhooks will be shown here once you create them.
</Trans>
</p>
</div>
)}
{webhooks && webhooks.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{webhooks?.map((webhook) => (
<div
key={webhook.id}
className={cn(
'border-border rounded-lg border p-4',
!webhook.enabled && 'bg-muted/40',
)}
>
<div className="flex flex-col gap-x-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="truncate font-mono text-xs">{webhook.id}</div>
<div className="mt-1.5 flex items-center gap-2">
<h5
className="max-w-[30rem] truncate text-sm sm:max-w-[18rem]"
title={webhook.webhookUrl}
>
{webhook.webhookUrl}
</h5>
<Badge variant={webhook.enabled ? 'neutral' : 'warning'} size="small">
{webhook.enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</Badge>
</div>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>
Listening to{' '}
{webhook.eventTriggers
.map((trigger) => toFriendlyWebhookEventName(trigger))
.join(', ')}
</Trans>
</p>
<p className="text-muted-foreground mt-2 text-xs">
<Trans>Created on {i18n.date(webhook.createdAt, DateTime.DATETIME_FULL)}</Trans>
</p>
</div>
<div className="mt-4 flex flex-shrink-0 gap-4 sm:mt-0">
<Button asChild variant="outline">
<Link to={`/t/${team.url}/settings/webhooks/${webhook.id}`}>
<Trans>Edit</Trans>
</Link>
</Button>
<WebhookDeleteDialog webhook={webhook}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</WebhookDeleteDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,5 @@
import TemplatePage, { loader } from '~/routes/_authenticated+/templates.$id._index';
export { loader };
export default TemplatePage;

View File

@ -0,0 +1,5 @@
import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates.$id.edit';
export { loader };
export default TemplateEditPage;

View File

@ -0,0 +1,5 @@
import TemplatesPage, { meta } from '~/routes/_authenticated+/templates._index';
export { meta };
export default TemplatesPage;

View File

@ -0,0 +1,221 @@
import { Trans } from '@lingui/react/macro';
import { DocumentSigningOrder, SigningStatus } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, redirect, useNavigate } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { TemplateBulkSendDialog } from '~/components/dialogs/template-bulk-send-dialog';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { DocumentReadOnlyFields } from '~/components/general/document/document-read-only-fields';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplatePageViewDocumentsTable } from '~/components/general/template/template-page-view-documents-table';
import { TemplatePageViewInformation } from '~/components/general/template/template-page-view-information';
import { TemplatePageViewRecentActivity } from '~/components/general/template/template-page-view-recent-activity';
import { TemplatePageViewRecipients } from '~/components/general/template/template-page-view-recipients';
import { TemplateType } from '~/components/general/template/template-type';
import { TemplatesTableActionDropdown } from '~/components/tables/templates-table-action-dropdown';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import type { Route } from './+types/templates.$id._index';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
const documentRootPath = formatDocumentsPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
throw redirect(templateRootPath);
}
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
throw redirect(templateRootPath);
}
return superLoaderJson({
user,
team,
template,
templateRootPath,
documentRootPath,
});
}
export default function TemplatePage() {
const { user, team, template, templateRootPath, documentRootPath } =
useSuperLoaderData<typeof loader>();
const { templateDocumentData, fields, recipients, templateMeta } = template;
const navigate = useNavigate();
// Remap to fit the DocumentReadOnlyFields component.
const readOnlyFields = fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
name: '',
email: '',
signingStatus: SigningStatus.NOT_SIGNED,
};
return {
...field,
recipient,
signature: null,
};
});
const mockedDocumentMeta = templateMeta
? {
...templateMeta,
signingOrder: templateMeta.signingOrder || DocumentSigningOrder.SEQUENTIAL,
documentId: 0,
}
: undefined;
return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link to={templateRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Templates</Trans>
</Link>
<div className="flex flex-row justify-between truncate">
<div>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
<TemplateBulkSendDialog templateId={template.id} recipients={template.recipients} />
<Button className="w-full" asChild>
<Link to={`${templateRootPath}/${template.id}/edit`}>
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
<Trans>Edit Template</Trans>
</Link>
</Button>
</div>
</div>
<div className="mt-6 grid w-full grid-cols-12 gap-8">
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<PDFViewer document={template} key={template.id} documentData={templateDocumentData} />
</CardContent>
</Card>
<DocumentReadOnlyFields
fields={readOnlyFields}
showFieldStatus={false}
documentMeta={mockedDocumentMeta}
/>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<div className="space-y-6">
<section className="border-border bg-widget flex flex-col rounded-xl border pb-4 pt-6">
<div className="flex flex-row items-center justify-between px-4">
<h3 className="text-foreground text-2xl font-semibold">
<Trans>Template</Trans>
</h3>
<div>
<TemplatesTableActionDropdown
row={template}
teamId={team?.id}
templateRootPath={templateRootPath}
onDelete={async () => navigate(templateRootPath)}
onMove={async ({ teamUrl, templateId }) =>
navigate(`${formatTemplatesPath(teamUrl)}/${templateId}`)
}
/>
</div>
</div>
<p className="text-muted-foreground mt-2 px-4 text-sm">
<Trans>Manage and view template</Trans>
</p>
<div className="mt-4 border-t px-4 pt-4">
<TemplateUseDialog
templateId={template.id}
templateSigningOrder={template.templateMeta?.signingOrder}
recipients={template.recipients}
documentRootPath={documentRootPath}
trigger={
<Button className="w-full">
<Trans>Use</Trans>
</Button>
}
/>
</div>
</section>
{/* Template information section. */}
<TemplatePageViewInformation template={template} userId={user.id} />
{/* Recipients section. */}
<TemplatePageViewRecipients template={template} templateRootPath={templateRootPath} />
{/* Recent activity section. */}
<TemplatePageViewRecentActivity
documentRootPath={documentRootPath}
templateId={template.id}
/>
</div>
</div>
</div>
<div className="mt-16" id="documents">
<h1 className="mb-4 text-2xl font-bold">
<Trans>Documents created from template</Trans>
</h1>
<TemplatePageViewDocumentsTable templateId={template.id} />
</div>
</div>
);
}

View File

@ -0,0 +1,107 @@
import { Trans } from '@lingui/react/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
import { TemplateType } from '~/components/general/template/template-type';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { TemplateDirectLinkDialogWrapper } from '../../components/dialogs/template-direct-link-dialog-wrapper';
import type { Route } from './+types/templates.$id.edit';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getSession(request);
let team: TGetTeamByUrlResponse | null = null;
if (params.teamUrl) {
team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
}
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
throw redirect(templateRootPath);
}
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
throw redirect(templateRootPath);
}
const isTemplateEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return superLoaderJson({
template,
isTemplateEnterprise,
templateRootPath,
});
}
export default function TemplateEditPage() {
const { template, isTemplateEnterprise, templateRootPath } = useSuperLoaderData<typeof loader>();
return (
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
<div className="flex flex-col justify-between sm:flex-row">
<div>
<Link
to={`${templateRootPath}/${template.id}`}
className="flex items-center text-[#7AC455] hover:opacity-80"
>
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
<Trans>Template</Trans>
</Link>
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
title={template.title}
>
{template.title}
</h1>
<div className="mt-2.5 flex items-center">
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
{template.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-4"
token={template.directLink.token}
enabled={template.directLink.enabled}
/>
)}
</div>
</div>
<div className="mt-2 sm:mt-0 sm:self-end">
<TemplateDirectLinkDialogWrapper template={template} />
</div>
</div>
<TemplateEditForm
className="mt-6"
initialTemplate={template}
templateRootPath={templateRootPath}
isEnterprise={isTemplateEnterprise}
/>
</div>
);
}

View File

@ -0,0 +1,94 @@
import { useEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { Bird } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useOptionalCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Templates');
}
export default function TemplatesPage() {
const [searchParams] = useSearchParams();
const team = useOptionalCurrentTeam();
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
const documentRootPath = formatDocumentsPath(team?.url);
const templateRootPath = formatTemplatesPath(team?.url);
const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery({
page: page,
perPage: perPage,
});
// Refetch the templates when the team URL changes.
useEffect(() => {
void refetch();
}, [team?.url]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<div className="flex flex-row items-center">
{team && (
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-xs text-gray-400">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
)}
<h1 className="truncate text-2xl font-semibold md:text-3xl">
<Trans>Templates</Trans>
</h1>
</div>
<div>
<TemplateCreateDialog templateRootPath={templateRootPath} teamId={team?.id} />
</div>
</div>
<div className="relative mt-5">
{data && data.count === 0 ? (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload one.
</Trans>
</p>
</div>
</div>
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
/>
)}
</div>
</div>
);
}