This commit is contained in:
David Nguyen
2025-01-31 23:17:50 +11:00
parent aec44b78d0
commit e20cb7e179
79 changed files with 3613 additions and 300 deletions

View File

@ -72,7 +72,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
});
if (url !== teamUrl) {
void navigate(`${WEBAPP_BASE_URL}/t/${url}/settings`);
await navigate(`${WEBAPP_BASE_URL}/t/${url}/settings`);
}
} catch (err) {
const error = AppError.parseError(err);

View File

@ -4,6 +4,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -20,14 +21,12 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useAuth } from '~/providers/auth';
export type AccountDeleteDialogProps = {
className?: string;
};
export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) => {
const { user } = useAuth();
const { user } = useSession();
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -3,7 +3,7 @@ import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Document } from '@prisma/client';
import { useNavigation } from 'react-router';
import { useNavigate } from 'react-router';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -28,7 +28,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigation();
const navigate = useNavigate();
const [reason, setReason] = useState('');
@ -49,7 +49,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
duration: 5000,
});
void navigate('/admin/documents');
await navigate('/admin/documents');
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),

View File

@ -51,15 +51,14 @@ export const DocumentDuplicateDialog = ({
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicateDocument.useMutation({
onSuccess: ({ documentId }) => {
void navigate(`${documentsPath}/${documentId}/edit`);
onSuccess: async ({ documentId }) => {
toast({
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
await navigate(`${documentsPath}/${documentId}/edit`);
onOpenChange(false);
},
});

View File

@ -9,6 +9,7 @@ import { History } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
@ -35,7 +36,6 @@ import {
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
import { useAuth } from '~/providers/auth';
import { useOptionalCurrentTeam } from '~/providers/team';
const FORM_ID = 'resend-email';
@ -56,7 +56,7 @@ export const ZResendDocumentFormSchema = z.object({
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
const { user } = useAuth();
const { user } = useSession();
const team = useOptionalCurrentTeam();
const { toast } = useToast();

View File

@ -78,7 +78,7 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
setOpen(false);
if (response.paymentRequired) {
void navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
return;
}

View File

@ -71,9 +71,9 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
duration: 5000,
});
setOpen(false);
await navigate('/settings/teams');
void navigate('/settings/teams');
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);

View File

@ -0,0 +1,144 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateCreateDialogProps = {
teamId?: number;
templateRootPath: string;
};
export const TemplateCreateDialog = ({ templateRootPath }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const onFileDrop = async (file: File) => {
if (isUploadingFile) {
return;
}
setIsUploadingFile(true);
try {
// const { type, data } = await putPdfFile(file);
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/file', {
method: 'POST',
body: formData,
})
.then((res) => res.json())
.catch((e) => {
console.error('Upload failed:', e);
throw new AppError('UPLOAD_FAILED');
});
// Why do we run this twice?
// const { id: templateDocumentDataId } = await createDocumentData({
// type: response.type,
// data: response.data,
// });
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId: response.id,
});
toast({
title: _(msg`Template document uploaded`),
description: _(
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
setShowTemplateCreateDialog(false);
await navigate(`${templateRootPath}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
setIsUploadingFile(false);
}
};
return (
<Dialog
open={showTemplateCreateDialog}
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
>
<DialogTrigger asChild>
{/* Todo: Wouldn't this break for google? */}
<Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>New Template</Trans>
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>New Template</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Templates allow you to quickly generate documents with pre-filled recipients and
fields.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
{isUploadingFile && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isUploadingFile}>
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,86 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateDeleteDialog = ({ id, open, onOpenChange }: TemplateDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
// router.refresh(); // Todo
toast({
title: _(msg`Template deleted`),
description: _(msg`Your template has been successfully deleted.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This template could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to delete this template?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={isPending}
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant="destructive"
loading={isPending}
onClick={async () => deleteTemplate({ templateId: id })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,46 @@
import { useState } from 'react';
import { Trans } from '@lingui/macro';
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
import { LinkIcon } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
export type TemplateDirectLinkDialogWrapperProps = {
template: Template & { directLink?: TemplateDirectLink | null; recipients: Recipient[] };
};
export const TemplateDirectLinkDialogWrapper = ({
template,
}: TemplateDirectLinkDialogWrapperProps) => {
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
return (
<div>
<Button
variant="outline"
className="px-3"
onClick={(e) => {
e.preventDefault();
setTemplateDirectLinkOpen(true);
}}
>
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
{template.directLink ? (
<Trans>Manage Direct Link</Trans>
) : (
<Trans>Create Direct Link</Trans>
)}
</Button>
<TemplateDirectLinkDialog
template={template}
open={isTemplateDirectLinkOpen}
onOpenChange={setTemplateDirectLinkOpen}
/>
</div>
);
};

View File

@ -0,0 +1,479 @@
import { useEffect, useMemo, useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import {
type Recipient,
RecipientRole,
type Template,
type TemplateDirectLink,
} from '@prisma/client';
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
import { Link } from 'react-router';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Switch } from '@documenso/ui/primitives/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkDialogProps = {
template: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
};
open: boolean;
onOpenChange: (_open: boolean) => void;
};
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
export const TemplateDirectLinkDialog = ({
template,
open,
onOpenChange,
}: TemplateDirectLinkDialogProps) => {
const { toast } = useToast();
const { quota, remaining } = useLimits();
const { _ } = useLingui();
const [, copy] = useCopyToClipboard();
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
const [token, setToken] = useState(template.directLink?.token ?? null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
token ? 'MANAGE' : 'ONBOARD',
);
const validDirectTemplateRecipients = useMemo(
() => template.recipients.filter((recipient) => recipient.role !== RecipientRole.CC),
[template.recipients],
);
const {
mutateAsync: createTemplateDirectLink,
isPending: isCreatingTemplateDirectLink,
reset: resetCreateTemplateDirectLink,
} = trpcReact.template.createTemplateDirectLink.useMutation({
onSuccess: (data) => {
setToken(data.token);
setIsEnabled(data.enabled);
setCurrentStep('MANAGE');
},
onError: () => {
setSelectedRecipientId(null);
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to create direct template access. Please try again later.`),
variant: 'destructive',
});
},
});
const { mutateAsync: toggleTemplateDirectLink, isPending: isTogglingTemplateAccess } =
trpcReact.template.toggleTemplateDirectLink.useMutation({
onSuccess: (data) => {
const enabledDescription = msg`Direct link signing has been enabled`;
const disabledDescription = msg`Direct link signing has been disabled`;
toast({
title: _(msg`Success`),
description: _(data.enabled ? enabledDescription : disabledDescription),
});
},
onError: (_ctx, data) => {
const enabledDescription = msg`An error occurred while enabling direct link signing.`;
const disabledDescription = msg`An error occurred while disabling direct link signing.`;
toast({
title: _(msg`Something went wrong`),
description: _(data.enabled ? enabledDescription : disabledDescription),
variant: 'destructive',
});
},
});
const { mutateAsync: deleteTemplateDirectLink, isPending: isDeletingTemplateDirectLink } =
trpcReact.template.deleteTemplateDirectLink.useMutation({
onSuccess: () => {
onOpenChange(false);
setToken(null);
toast({
title: _(msg`Success`),
description: _(msg`Direct template link deleted`),
duration: 5000,
});
setToken(null);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We encountered an error while removing the direct template link. Please try again later.`,
),
variant: 'destructive',
});
},
});
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The direct link has been copied to your clipboard`),
});
});
const onRecipientTableRowClick = async (recipientId: number) => {
if (isLoading) {
return;
}
setSelectedRecipientId(recipientId);
await createTemplateDirectLink({
templateId: template.id,
directRecipientId: recipientId,
});
};
const isLoading =
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
useEffect(() => {
resetCreateTemplateDirectLink();
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
setSelectedRecipientId(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<fieldset disabled={isLoading} className="relative">
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ token, currentStep })
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Create Direct Signing Link</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Here's how it works:</Trans>
</DialogDescription>
</DialogHeader>
<ul className="mt-4 space-y-4 pl-12">
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
<li className="relative" key={index}>
<div className="absolute -left-12">
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
{index + 1}
</div>
</div>
<h3 className="font-semibold">{_(step.title)}</h3>
<p className="text-muted-foreground mt-1 text-sm">{_(step.description)}</p>
</li>
))}
</ul>
{remaining.directTemplates === 0 && (
<Alert variant="warning">
<AlertTitle>
<Trans>
Direct template link usage exceeded ({quota.directTemplates}/
{quota.directTemplates})
</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
You have reached the maximum limit of {quota.directTemplates} direct
templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
to="/settings/billing"
>
Upgrade your account to continue!
</Link>
</Trans>
</AlertDescription>
</Alert>
)}
{remaining.directTemplates !== 0 && (
<DialogFooter className="mx-auto mt-4">
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
<Trans> Enable direct link signing</Trans>
</Button>
</DialogFooter>
)}
</DialogContent>
))
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
<DialogContent className="relative">
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
</div>
)}
<DialogHeader>
<DialogTitle>
<Trans>Choose Direct Link Recipient</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Choose an existing recipient from below to continue</Trans>
</DialogDescription>
</DialogHeader>
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Trans>Recipient</Trans>
</TableHead>
<TableHead>
<Trans>Role</Trans>
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{validDirectTemplateRecipients.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="h-16 text-center">
<p className="text-muted-foreground">
<Trans>No valid recipients found</Trans>
</p>
</TableCell>
</TableRow>
)}
{validDirectTemplateRecipients.map((row) => (
<TableRow
className="cursor-pointer"
key={row.id}
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-muted-foreground text-sm">
<p>{row.name}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-muted-foreground text-sm">
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
</TableCell>
<TableCell>
{selectedRecipientId === row.id ? (
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
) : (
<CircleIcon className="h-5 w-5 text-neutral-300" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
{!template.recipients.some(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
) && (
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-muted-foreground text-sm">
<Trans>Or</Trans>
</p>
)}
<Button
type="button"
className="mt-2"
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
onClick={async () =>
createTemplateDirectLink({
templateId: template.id,
})
}
>
<Trans>Create one automatically</Trans>
</Button>
</div>
</DialogFooter>
)}
</DialogContent>
))
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>
<Trans>Direct Link Signing</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Manage the direct link signing for this template</Trans>
</DialogDescription>
</DialogHeader>
<div>
<div className="flex flex-row items-center justify-between">
<Label className="flex flex-row">
<Trans>Enable Direct Link Signing</Trans>
<Tooltip>
<TooltipTrigger tabIndex={-1} className="ml-2">
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
<Trans>
Disabling direct link signing will prevent anyone from accessing the
link.
</Trans>
</TooltipContent>
</Tooltip>
</Label>
<Switch
className="mt-2"
checked={isEnabled}
onCheckedChange={(value) => setIsEnabled(value)}
/>
</div>
<div className="mt-2">
<Label htmlFor="copy-direct-link">
<Trans>Copy Shareable Link</Trans>
</Label>
<div className="relative mt-1">
<Input
id="copy-direct-link"
disabled
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
readOnly
className="pr-12"
/>
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
<Button
variant="none"
type="button"
className="h-8 w-8"
onClick={() => void onCopyClick(token)}
>
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
</Button>
</div>
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button
type="button"
variant="destructive"
className="mr-auto w-full sm:w-auto"
loading={isDeletingTemplateDirectLink}
onClick={() => setCurrentStep('CONFIRM_DELETE')}
>
<Trans>Remove</Trans>
</Button>
<Button
type="button"
loading={isTogglingTemplateAccess}
onClick={async () => {
await toggleTemplateDirectLink({
templateId: template.id,
enabled: isEnabled,
}).catch((e) => null);
onOpenChange(false);
}}
>
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
))
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
<DialogContent className="relative">
<DialogHeader>
<DialogTitle>
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Please note that proceeding will remove direct linking recipient and turn it
into a placeholder.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setCurrentStep('MANAGE')}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant="destructive"
loading={isDeletingTemplateDirectLink}
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
>
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</DialogContent>
))
.otherwise(() => null)}
</AnimateGenericFadeInOut>
</fieldset>
</Dialog>
);
};

View File

@ -0,0 +1,90 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDuplicateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateDuplicateDialog = ({
id,
open,
onOpenChange,
}: TemplateDuplicateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isPending } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
// router.refresh(); // Todo
toast({
title: _(msg`Template duplicated`),
description: _(msg`Your template has been duplicated successfully.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while duplicating template.`),
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to duplicate this template?</Trans>
</DialogTitle>
<DialogDescription className="pt-2">
<Trans>Your template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
disabled={isPending}
variant="secondary"
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={async () =>
duplicateTemplate({
templateId: id,
})
}
>
<Trans>Duplicate</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,136 @@
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateMoveDialogProps = {
templateId: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateMoveDialog = ({ templateId, open, onOpenChange }: TemplateMoveDialogProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [selectedTeamId, setSelectedTeamId] = useState<number | null>(null);
const { data: teams, isLoading: isLoadingTeams } = trpc.team.getTeams.useQuery();
const { mutateAsync: moveTemplate, isPending } = trpc.template.moveTemplateToTeam.useMutation({
onSuccess: () => {
// router.refresh(); // Todo
toast({
title: _(msg`Template moved`),
description: _(msg`The template has been successfully moved to the selected team.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (err) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => msg`Template not found or already associated with a team.`,
)
.with(AppErrorCode.UNAUTHORIZED, () => msg`You are not a member of this team.`)
.otherwise(() => msg`An error occurred while moving the template.`);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
},
});
const onMove = async () => {
if (!selectedTeamId) {
return;
}
await moveTemplate({ templateId, teamId: selectedTeamId });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Move Template to Team</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Select a team to move this template to. This action cannot be undone.</Trans>
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedTeamId(Number(value))}>
<SelectTrigger>
<SelectValue placeholder={_(msg`Select a team`)} />
</SelectTrigger>
<SelectContent>
{isLoadingTeams ? (
<SelectItem value="loading" disabled>
<Trans>Loading teams...</Trans>
</SelectItem>
) : (
teams?.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
<div className="flex items-center gap-4">
<Avatar className="h-8 w-8">
{team.avatarImageId && (
<AvatarImage
src={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${team.avatarImageId}`}
/>
)}
<AvatarFallback className="text-sm text-gray-400">
{team.name.slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<span>{team.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={onMove} loading={isPending} disabled={!selectedTeamId || isPending}>
{isPending ? <Trans>Moving...</Trans> : <Trans>Move</Trans>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,587 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { InfoIcon, Plus, Upload, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import * as z from 'zod';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z
.object({
distributeDocument: z.boolean(),
useCustomDocument: z.boolean().default(false),
customDocumentData: z
.any()
.refine((data) => data instanceof File || data === undefined)
.optional(),
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
name: z.string(),
signingOrder: z.number().optional(),
}),
),
})
// Display exactly which rows are duplicates.
.superRefine((items, ctx) => {
const uniqueEmails = new Map<string, number>();
for (const [index, recipients] of items.recipients.entries()) {
const email = recipients.email.toLowerCase();
const firstFoundIndex = uniqueEmails.get(email);
if (firstFoundIndex === undefined) {
uniqueEmails.set(email, index);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', index, 'email'],
});
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Emails must be unique',
path: ['recipients', firstFoundIndex, 'email'],
});
}
});
type TAddRecipientsForNewDocumentSchema = z.infer<typeof ZAddRecipientsForNewDocumentSchema>;
export type TemplateUseDialogProps = {
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string;
trigger?: React.ReactNode;
};
export function TemplateUseDialog({
recipients,
documentDistributionMethod = DocumentDistributionMethod.EMAIL,
documentRootPath,
templateId,
templateSigningOrder,
trigger,
}: TemplateUseDialogProps) {
const { toast } = useToast();
const { _ } = useLingui();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: undefined,
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
const isRecipientEmailPlaceholder = recipient.email.match(
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
);
const isRecipientNamePlaceholder = recipient.name.match(
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
);
return {
id: recipient.id,
name: !isRecipientNamePlaceholder ? recipient.name : '',
email: !isRecipientEmailPlaceholder ? recipient.email : '',
signingOrder: recipient.signingOrder ?? undefined,
};
}),
},
});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
let customDocumentDataId: string | undefined = undefined;
if (data.useCustomDocument && data.customDocumentData) {
// const customDocumentData = await putPdfFile(data.customDocumentData);
// Todo
const formData = new FormData();
formData.append('file', data.customDocumentData);
const customDocumentData = await fetch('/api/file', {
method: 'POST',
body: formData,
})
.then((res) => res.json())
.catch((e) => {
console.error('Upload failed:', e);
throw new AppError('UPLOAD_FAILED');
});
customDocumentDataId = customDocumentData.id;
}
const { id } = await createDocumentFromTemplate({
templateId,
recipients: data.recipients,
distributeDocument: data.distributeDocument,
customDocumentDataId,
});
toast({
title: _(msg`Document created`),
description: _(msg`Your document has been created from the template successfully.`),
duration: 5000,
});
let documentPath = `${documentRootPath}/${id}`;
if (
data.distributeDocument &&
documentDistributionMethod === DocumentDistributionMethod.NONE
) {
documentPath += '?action=view-signing-links';
}
await navigate(documentPath);
} catch (err) {
const error = AppError.parseError(err);
const toastPayload: Toast = {
title: _(msg`Error`),
description: _(msg`An error occurred while creating document from template.`),
variant: 'destructive',
};
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = _(
msg`The document was created but could not be sent to recipients.`,
);
}
toast(toastPayload);
}
};
const { fields: formRecipients } = useFieldArray({
control: form.control,
name: 'recipients',
});
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="bg-background">
<Plus className="-ml-1 mr-2 h-4 w-4" />
<Trans>Use Template</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
<Trans>Create document from template</Trans>
</DialogTitle>
<DialogDescription>
{recipients.length === 0 ? (
<Trans>A draft document will be created</Trans>
) : (
<Trans>Add the recipients to create the document with</Trans>
)}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="flex h-full flex-col" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{formRecipients.map((recipient, index) => (
<div className="flex w-full flex-row space-x-4" key={recipient.id}>
{templateSigningOrder === DocumentSigningOrder.SEQUENTIAL && (
<FormField
control={form.control}
name={`recipients.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('w-20', {
'mt-8': index === 0,
})}
>
<FormControl>
<Input
{...field}
disabled
className="items-center justify-center"
value={
field.value?.toString() ||
recipients[index]?.signingOrder?.toString()
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name={`recipients.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel required>
<Trans>Email</Trans>
</FormLabel>
)}
<FormControl>
<Input
{...field}
placeholder={recipients[index].email || _(msg`Email`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`recipients.${index}.name`}
render={({ field }) => (
<FormItem className="w-full">
{index === 0 && (
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
)}
<FormControl>
<Input
{...field}
placeholder={recipients[index].name || _(msg`Name`)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
))}
{recipients.length > 0 && (
<div className="mt-4 flex flex-row items-center">
<FormField
control={form.control}
name="distributeDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="distributeDocument"
className="h-5 w-5"
checked={field.value}
onCheckedChange={field.onChange}
/>
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
The document will be immediately sent to recipients if this
is checked.
</Trans>
</p>
<p>
<Trans>
Otherwise, the document will be created as a draft.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
Create the document as pending and ready to sign.
</Trans>
</p>
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send
to the recipients through your method of choice.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
)}
</div>
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="useCustomDocument"
render={({ field }) => (
<FormItem>
<div className="flex flex-row items-center">
<Checkbox
id="useCustomDocument"
className="h-5 w-5"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue('customDocumentData', undefined);
}
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<p>
<Trans>
Upload a custom document to use instead of the template's default
document
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</FormItem>
)}
/>
{form.watch('useCustomDocument') && (
<div className="my-4">
<FormField
control={form.control}
name="customDocumentData"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="w-full space-y-4">
<label
className={cn(
'text-muted-foreground hover:border-muted-foreground/50 group relative flex min-h-[150px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-gray-300 px-6 py-10 transition-colors',
{
'border-destructive hover:border-destructive':
form.formState.errors.customDocumentData,
},
)}
>
<div className="text-center">
{!field.value && (
<>
<Upload className="text-muted-foreground/50 mx-auto h-10 w-10" />
<div className="mt-4 flex text-sm leading-6">
<span className="text-muted-foreground relative">
<Trans>
<span className="text-primary font-semibold">
Click to upload
</span>{' '}
or drag and drop
</Trans>
</span>
</div>
<p className="text-muted-foreground/80 text-xs">
PDF files only
</p>
</>
)}
{field.value && (
<div className="text-muted-foreground space-y-1">
<p className="text-sm font-medium">{field.value.name}</p>
<p className="text-muted-foreground/60 text-xs">
{(field.value.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
)}
</div>
<input
type="file"
className="absolute h-full w-full opacity-0"
accept=".pdf,application/pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) {
field.onChange(undefined);
return;
}
if (file.type !== 'application/pdf') {
form.setError('customDocumentData', {
type: 'manual',
message: _(msg`Please select a PDF file`),
});
return;
}
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
msg`File size exceeds the limit of ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB`,
),
});
return;
}
field.onChange(file);
}}
/>
{field.value && (
<div className="absolute right-2 top-2">
<Button
type="button"
variant="destructive"
className="h-6 w-6 p-0"
onClick={(e) => {
e.preventDefault();
field.onChange(undefined);
}}
>
<X className="h-4 w-4" />
<div className="sr-only">
<Trans>Clear file</Trans>
</div>
</Button>
</div>
)}
</label>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Close</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={form.formState.isSubmitting}>
{!form.getValues('distributeDocument') ? (
<Trans>Create as draft</Trans>
) : documentDistributionMethod === DocumentDistributionMethod.EMAIL ? (
<Trans>Create and send</Trans>
) : (
<Trans>Create signing links</Trans>
)}
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -7,6 +7,7 @@ import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
@ -16,8 +17,6 @@ import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useAuth } from '~/providers/auth';
export type DocumentUploadDropzoneProps = {
className?: string;
team?: {
@ -33,7 +32,7 @@ export const DocumentUploadDropzone = ({ className, team }: DocumentUploadDropzo
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
DEFAULT_DOCUMENT_TIME_ZONE;
const { user } = useAuth();
const { user } = useSession();
const { _ } = useLingui();
const { toast } = useToast();
@ -64,16 +63,29 @@ export const DocumentUploadDropzone = ({ className, team }: DocumentUploadDropzo
// Todo
// const { type, data } = await putPdfFile(file);
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/file', {
method: 'POST',
body: formData,
})
.then((res) => res.json())
.catch((e) => {
console.error('Upload failed:', e);
throw new AppError('UPLOAD_FAILED');
});
// const { id: documentDataId } = await createDocumentData({
// type,
// data,
// });
// const { id } = await createDocument({
// title: file.name,
// documentDataId,
// timezone: userTimezone,
// });
const { id } = await createDocument({
title: file.name,
documentDataId: response.id, // todo
timezone: userTimezone,
});
void refreshLimits();

View File

@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { AppError } from '@documenso/lib/errors/app-error';
import { base64 } from '@documenso/lib/universal/base64';
@ -26,7 +27,6 @@ import {
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useAuth } from '~/providers/auth';
import { useOptionalCurrentTeam } from '~/providers/team';
export const ZAvatarImageFormSchema = z.object({
@ -40,7 +40,7 @@ export type AvatarImageFormProps = {
};
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
const { user } = useAuth();
const { user } = useSession();
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -20,8 +21,6 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useAuth } from '~/providers/auth';
export const ZProfileFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
signature: z.string().min(1, 'Signature Pad cannot be empty'),
@ -41,7 +40,7 @@ export type ProfileFormProps = {
export const ProfileForm = ({ className }: ProfileFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useAuth();
const { user } = useSession();
const form = useForm<TProfileFormSchema>({
values: {

View File

@ -36,7 +36,7 @@ export const SearchParamSelector = ({ children, paramKey, isValueValid }: Search
params.delete(paramKey);
}
void navigate(`${pathname}?${params.toString()}`, { scroll: false });
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (

View File

@ -223,7 +223,7 @@ export const SignInForm = ({
}
if (error.code === AuthenticationErrorCode.UnverifiedEmail) {
void navigate('/unverified-account');
await navigate('/unverified-account');
toast({
title: _(msg`Unable to sign in`),

View File

@ -124,7 +124,7 @@ export const SignUpForm = ({
try {
await authClient.emailPassword.signUp({ name, email, password, signature, url });
void navigate(`/unverified-account`);
await navigate(`/unverified-account`);
toast({
title: _(msg`Registration Successful`),

View File

@ -1,17 +1,13 @@
'use client';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -81,7 +77,8 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
let uploadedBrandingLogo = settings?.brandingLogo;
if (brandingLogo) {
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
// Todo
// uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
}
if (brandingLogo === null) {
@ -118,12 +115,26 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) {
void getFile(file).then((binaryData) => {
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
// Todo
// Todo
// Todo
void fetch(`/api/file?key=${file.key}`, {
method: 'GET',
})
.then((res) => res.json())
.then((data) => {
const objectUrl = URL.createObjectURL(new Blob([data.binaryData]));
setPreviewUrl(objectUrl);
setHasLoadedPreview(true);
});
setPreviewUrl(objectUrl);
setHasLoadedPreview(true);
});
// void getFile(file).then((binaryData) => {
// const objectUrl = URL.createObjectURL(new Blob([binaryData]));
// setPreviewUrl(objectUrl);
// setHasLoadedPreview(true);
// });
return;
}

View File

@ -1,19 +1,17 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useSession } from 'next-auth/react';
import type { Team, TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import {
SUPPORTED_LANGUAGES,
SUPPORTED_LANGUAGE_CODES,
isValidLanguageCode,
} from '@documenso/lib/constants/i18n';
import type { Team, TeamGlobalSettings } from '@documenso/prisma/client';
import { DocumentVisibility } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -56,9 +54,9 @@ export const TeamDocumentPreferencesForm = ({
}: TeamDocumentPreferencesFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { data } = useSession();
const { user } = useSession();
const placeholderEmail = data?.user.email ?? 'user@example.com';
const placeholderEmail = user.email ?? 'user@example.com';
const { mutateAsync: updateTeamDocumentPreferences } =
trpc.team.updateTeamDocumentSettings.useMutation();

View File

@ -3,13 +3,13 @@ import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
import { useAuth } from '~/providers/auth';
export const UpcomingProfileClaimTeaser = () => {
const { user } = useAuth();
const { user } = useSession();
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -294,18 +294,15 @@ export const DocumentEditForm = ({
duration: 5000,
});
void navigate(documentRootPath);
return;
}
if (document.status === DocumentStatus.DRAFT) {
await navigate(documentRootPath);
} else if (document.status === DocumentStatus.DRAFT) {
toast({
title: _(msg`Links Generated`),
description: _(msg`Signing links have been generated for this document.`),
duration: 5000,
});
} else {
void navigate(`${documentRootPath}/${document.id}`);
await navigate(`${documentRootPath}/${document.id}`);
}
} catch (err) {
console.error(err);

View File

@ -7,13 +7,12 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useAuth } from '~/providers/auth';
export type DocumentPageViewButtonProps = {
document: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
@ -23,7 +22,7 @@ export type DocumentPageViewButtonProps = {
};
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
const { user } = useAuth();
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();

View File

@ -17,6 +17,7 @@ import {
import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -33,7 +34,6 @@ import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialo
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
import { useAuth } from '~/providers/auth';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentPageViewDropdownProps = {
@ -45,7 +45,7 @@ export type DocumentPageViewDropdownProps = {
};
export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownProps) => {
const { user } = useAuth();
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();

View File

@ -1,7 +1,3 @@
'use client';
import Link from 'next/link';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
@ -15,6 +11,7 @@ import {
PenIcon,
PlusIcon,
} from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@ -51,7 +48,7 @@ export const DocumentPageViewRecipients = ({
{document.status !== DocumentStatus.COMPLETED && (
<Link
href={`${documentRootPath}/${document.id}/edit?step=signers`}
to={`${documentRootPath}/${document.id}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>

View File

@ -1,10 +1,10 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { TeamMemberRole, TeamTransferVerification } from '@prisma/client';
import { AnimatePresence } from 'framer-motion';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';

View File

@ -0,0 +1,47 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Link2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkBadgeProps = {
token: string;
enabled: boolean;
className?: string;
};
export const TemplateDirectLinkBadge = ({
token,
enabled,
className,
}: TemplateDirectLinkBadgeProps) => {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The direct link has been copied to your clipboard`),
});
});
return (
<button
title="Copy direct link"
className={cn(
'flex flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600',
className,
)}
onClick={async () => onCopyClick(token)}
>
<Link2Icon className="mr-1 h-3 w-3" />
{enabled ? <Trans>direct link</Trans> : <Trans>direct link disabled</Trans>}
</button>
);
};

View File

@ -0,0 +1,294 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { TTemplate } from '@documenso/lib/types/template';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateEditFormProps = {
className?: string;
initialTemplate: TTemplate;
isEnterprise: boolean;
templateRootPath: string;
};
type EditTemplateStep = 'settings' | 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const TemplateEditForm = ({
initialTemplate,
className,
isEnterprise,
templateRootPath,
}: TemplateEditFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const [step, setStep] = useState<EditTemplateStep>('settings');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const utils = trpc.useUtils();
const { data: template, refetch: refetchTemplate } = trpc.template.getTemplateById.useQuery(
{
templateId: initialTemplate.id,
},
{
initialData: initialTemplate,
...SKIP_QUERY_BATCH_META,
},
);
const { recipients, fields, templateDocumentData } = template;
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: msg`General`,
description: msg`Configure general settings for the template.`,
stepIndex: 1,
},
signers: {
title: msg`Add Placeholders`,
description: msg`Add all relevant placeholders for each recipient.`,
stepIndex: 2,
},
fields: {
title: msg`Add Fields`,
description: msg`Add all relevant fields for each recipient.`,
stepIndex: 3,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplate.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: setRecipients } = trpc.recipient.setTemplateRecipients.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: {
...data.meta,
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating the document settings.`),
variant: 'destructive',
});
}
};
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await Promise.all([
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers,
}),
]);
setStep('fields');
} catch (err) {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
await updateTemplateSettings({
templateId: template.id,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('field_')) {
localStorage.removeItem(key);
}
}
toast({
title: _(msg`Template saved`),
description: _(msg`Your templates has been saved successfully.`),
duration: 5000,
});
await navigate(templateRootPath);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding fields.`),
variant: 'destructive',
});
}
};
/**
* Refresh the data in the background when steps change.
*/
useEffect(() => {
void refetchTemplate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={templateDocumentData.id}
documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
currentTeamMemberRole={team?.currentTeamMember?.role}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
signingOrder={template.templateMeta?.signingOrder}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
teamId={team?.id}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

@ -0,0 +1,271 @@
import { useMemo } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
import { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
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 { SelectItem } from '@documenso/ui/primitives/select';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { DocumentStatus } from '~/components/formatter/document-status';
import { SearchParamSelector } from '~/components/forms/search-param-selector';
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
import { DataTableTitle } from '~/components/tables/documents-table-title';
import { useOptionalCurrentTeam } from '~/providers/team';
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
DOCUMENT: msg`Document`,
TEMPLATE: msg`Template`,
TEMPLATE_DIRECT_LINK: msg`Direct link`,
};
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
source: z
.nativeEnum(DocumentSource)
.optional()
.catch(() => undefined),
status: z
.nativeEnum(DocumentStatusEnum)
.optional()
.catch(() => undefined),
});
type TemplatePageViewDocumentsTableProps = {
templateId: number;
};
export const TemplatePageViewDocumentsTable = ({
templateId,
}: TemplatePageViewDocumentsTableProps) => {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useOptionalCurrentTeam();
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
{
templateId,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
query: parsedSearchParams.query,
source: parsedSearchParams.source,
status: parsedSearchParams.status,
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.recipients}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
size: 140,
},
{
header: () => (
<div className="flex flex-row items-center">
<Trans>Source</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Trans>Template</Trans>
</h2>
<p>
<Trans>
This document was created by you or a team member using the template above.
</Trans>
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Trans>Direct Link</Trans>
</h2>
<p>
<Trans>This document was created using a direct link.</Trans>
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
{_(DOCUMENT_SOURCE_LABELS[row.original.source])}
</div>
),
},
{
id: 'actions',
header: _(msg`Actions`),
cell: ({ row }) => (
<div className="flex items-center space-x-2">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<div>
<div className="mb-4 flex flex-row space-x-4">
<DocumentSearch />
<SearchParamSelector
paramKey="status"
isValueValid={(value) =>
[...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Status</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.COMPLETED}>
<Trans>Completed</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.PENDING}>
<Trans>Pending</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.DRAFT}>
<Trans>Draft</Trans>
</SelectItem>
</SearchParamSelector>
<SearchParamSelector
paramKey="source"
isValueValid={(value) =>
[...DocumentSource.TEMPLATE].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Source</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE}>
<Trans>Template</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
<Trans>Direct Link</Trans>
</SelectItem>
</SearchParamSelector>
<PeriodSelector />
</div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell className="py-4 pr-4">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};

View File

@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Template, User } from '@prisma/client';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
export type TemplatePageViewInformationProps = {
userId: number;
template: Template & {
user: Pick<User, 'id' | 'name' | 'email'>;
};
};
export const TemplatePageViewInformation = ({
template,
userId,
}: TemplatePageViewInformationProps) => {
const isMounted = useIsMounted();
const { _, i18n } = useLingui();
const templateInformation = useMemo(() => {
return [
{
description: msg`Uploaded by`,
value:
userId === template.userId ? _(msg`You`) : (template.user.name ?? template.user.email),
},
{
description: msg`Created`,
value: i18n.date(template.createdAt, { dateStyle: 'medium' }),
},
{
description: msg`Last modified`,
value: DateTime.fromJSDate(template.updatedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, template, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">
<Trans>Information</Trans>
</h1>
<ul className="divide-y border-t">
{templateInformation.map((item, i) => (
<li
key={i}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<span className="text-muted-foreground">{_(item.description)}</span>
<span>{item.value}</span>
</li>
))}
</ul>
</section>
);
};

View File

@ -0,0 +1,155 @@
import { Trans } from '@lingui/macro';
import { DocumentSource } from '@prisma/client';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type TemplatePageViewRecentActivityProps = {
templateId: number;
documentRootPath: string;
};
export const TemplatePageViewRecentActivity = ({
templateId,
documentRootPath,
}: TemplatePageViewRecentActivityProps) => {
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
templateId,
orderByColumn: 'createdAt',
orderByDirection: 'asc',
perPage: 5,
});
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">
<Trans>Recent documents</Trans>
</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">
<Trans>Unable to load documents</Trans>
</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
<Trans>Click here to retry</Trans>
</button>
</div>
)}
{data && (
<>
<ul role="list" className="space-y-6 p-4">
{data.data.length > 0 && results.totalPages > 1 && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="bg-border w-px" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={() => {
window.scrollTo({
top: document.getElementById('documents')?.offsetTop,
behavior: 'smooth',
});
}}
className="text-foreground/70 hover:text-muted-foreground flex items-center text-xs"
>
<Trans>View more</Trans>
</button>
</li>
)}
{results.data.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">
<Trans>No recent documents</Trans>
</p>
</div>
)}
{results.data.map((document, documentIndex) => (
<li key={document.id} className="relative flex gap-x-4">
<div
className={cn(
documentIndex === results.data.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<Link
to={`${documentRootPath}/${document.id}`}
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
>
{match(document.source)
.with(DocumentSource.DOCUMENT, DocumentSource.TEMPLATE, () => (
<Trans>
Document created by <span className="font-bold">{document.user.name}</span>
</Trans>
))
.with(DocumentSource.TEMPLATE_DIRECT_LINK, () => (
<Trans>
Document created using a <span className="font-bold">direct link</span>
</Trans>
))
.exhaustive()}
</Link>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
{DateTime.fromJSDate(document.createdAt).toRelative({ style: 'short' })}
</time>
</li>
))}
</ul>
<Button
className="mx-4 mb-4"
onClick={() => {
window.scrollTo({
top: document.getElementById('documents')?.offsetTop,
behavior: 'smooth',
});
}}
>
<Trans>View all related documents</Trans>
</Button>
</>
)}
</section>
);
};

View File

@ -0,0 +1,68 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Recipient, Template } from '@prisma/client';
import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
template: Template & {
recipients: Recipient[];
};
templateRootPath: string;
};
export const TemplatePageViewRecipients = ({
template,
templateRootPath,
}: TemplatePageViewRecipientsProps) => {
const { _ } = useLingui();
const recipients = template.recipients;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">
<Trans>Recipients</Trans>
</h1>
<Link
to={`${templateRootPath}/${template.id}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
</div>
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
</li>
))}
</ul>
</section>
);
};

View File

@ -1,13 +1,10 @@
'use client';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { useSearchParams } from 'react-router';
import { UAParser } from 'ua-parser-js';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
@ -32,7 +29,7 @@ const dateFormat: DateTimeFormatOptions = {
export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
const { _, i18n } = useLingui();
const searchParams = useSearchParams();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));

View File

@ -7,12 +7,13 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useAuth } from '~/providers/auth';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentsTableActionButtonProps = {
row: Document & {
@ -20,14 +21,15 @@ export type DocumentsTableActionButtonProps = {
recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'>;
};
export const DocumentsTableActionButton = ({ row, team }: DocumentsTableActionButtonProps) => {
const { user } = useAuth();
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const team = useOptionalCurrentTeam();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
const isOwner = row.user.id === user.id;

View File

@ -20,6 +20,7 @@ import {
import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -37,7 +38,7 @@ import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate
import { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
import { useAuth } from '~/providers/auth';
import { useOptionalCurrentTeam } from '~/providers/team';
export type DocumentsTableActionDropdownProps = {
row: Document & {
@ -45,11 +46,12 @@ export type DocumentsTableActionDropdownProps = {
recipients: Recipient[];
team: Pick<Team, 'id' | 'url'> | null;
};
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
};
export const DocumentsTableActionDropdown = ({ row, team }: DocumentsTableActionDropdownProps) => {
const { user } = useAuth();
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
const { user } = useSession();
const team = useOptionalCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
@ -229,7 +231,6 @@ export const DocumentsTableActionDropdown = ({ row, team }: DocumentsTableAction
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
team={team}
/>
</DropdownMenu>
);

View File

@ -2,10 +2,9 @@ import type { Document, Recipient, Team, User } from '@prisma/client';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { useAuth } from '~/providers/auth';
export type DataTableTitleProps = {
row: Document & {
user: Pick<User, 'id' | 'name' | 'email'>;
@ -16,7 +15,7 @@ export type DataTableTitleProps = {
};
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { user } = useAuth();
const { user } = useSession();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);

View File

@ -9,6 +9,7 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
@ -20,7 +21,6 @@ import { TableCell } from '@documenso/ui/primitives/table';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
import { useAuth } from '~/providers/auth';
import { DocumentsTableActionButton } from './documents-table-action-button';
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
@ -86,8 +86,8 @@ export const DocumentsTable = ({
cell: ({ row }) =>
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton team={team} row={row.original} />
<DocumentsTableActionDropdown team={team} row={row.original} />
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
@ -169,7 +169,7 @@ type DataTableTitleProps = {
};
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { user } = useAuth();
const { user } = useSession();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);

View File

@ -20,7 +20,7 @@ import {
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
import { ManagePublicTemplateDialog } from '~/components/dialogs/public-profile-template-manage-dialog';
type DirectTemplate = FindTemplateRow & {
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;

View File

@ -0,0 +1,116 @@
import { useState } from 'react';
import { Trans } from '@lingui/macro';
import type { Recipient, Template, TemplateDirectLink } from '@prisma/client';
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { TemplateDeleteDialog } from '../dialogs/template-delete-dialog';
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
import { TemplateDuplicateDialog } from '../dialogs/template-duplicate-dialog';
import { TemplateMoveDialog } from '../dialogs/template-move-dialog';
export type TemplatesTableActionDropdownProps = {
row: Template & {
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
};
templateRootPath: string;
teamId?: number;
};
export const TemplatesTableActionDropdown = ({
row,
templateRootPath,
teamId,
}: TemplatesTableActionDropdownProps) => {
const { user } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
const [isMoveDialogOpen, setMoveDialogOpen] = useState(false);
const isOwner = row.userId === user.id;
const isTeamTemplate = row.teamId === teamId;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
<Link to={`${templateRootPath}/${row.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
<Trans>Edit</Trans>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDuplicateDialogOpen(true)}
>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
<Share2Icon className="mr-2 h-4 w-4" />
<Trans>Direct link</Trans>
</DropdownMenuItem>
{!teamId && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<MoveRight className="mr-2 h-4 w-4" />
<Trans>Move to Team</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!isOwner && !isTeamTemplate}
onClick={() => setDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
<TemplateDuplicateDialog
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
<TemplateDirectLinkDialog
template={row}
open={isTemplateDirectLinkDialogOpen}
onOpenChange={setTemplateDirectLinkDialogOpen}
/>
<TemplateMoveDialog
templateId={row.id}
open={isMoveDialogOpen}
onOpenChange={setMoveDialogOpen}
/>
<TemplateDeleteDialog
id={row.id}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
</DropdownMenu>
);
};

View File

@ -0,0 +1,259 @@
import { useMemo, useTransition } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
import { Link } from 'react-router';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
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 { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { TemplateType } from '~/components/formatter/template-type';
import { useOptionalCurrentTeam } from '~/providers/team';
import { TemplateUseDialog } from '../dialogs/template-use-dialog';
import { TemplateDirectLinkBadge } from '../pages/template/template-direct-link-badge';
import { TemplatesTableActionDropdown } from './templates-table-action-dropdown';
type TemplatesTableProps = {
data?: TFindTemplatesResponse;
isLoading?: boolean;
isLoadingError?: boolean;
documentRootPath: string;
templateRootPath: string;
};
type TemplatesTableRow = TFindTemplatesResponse['data'][number];
export const TemplatesTable = ({
data,
isLoading,
isLoadingError,
documentRootPath,
templateRootPath,
}: TemplatesTableProps) => {
const { _, i18n } = useLingui();
const { remaining } = useLimits();
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const formatTemplateLink = (row: TemplatesTableRow) => {
const isCurrentTeamTemplate = team?.url && row.team?.url === team?.url;
const path = formatTemplatesPath(isCurrentTeamTemplate ? team?.url : undefined);
return `${path}/${row.id}`;
};
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: _(msg`Title`),
cell: ({ row }) => (
<Link
to={formatTemplateLink(row.original)}
className="block max-w-[10rem] cursor-pointer truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.original.title}
</Link>
),
},
{
header: () => (
<div className="flex flex-row items-center">
<Trans>Type</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
<Trans>Public</Trans>
</h2>
<p>
<Trans>
Public templates are connected to your public profile. Any modifications to
public templates will also appear in your public profile.
</Trans>
</p>
</li>
<li>
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
<Link2Icon className="mr-1 h-3 w-3" />
<Trans>direct link</Trans>
</div>
<p>
<Trans>
Direct link templates contain one dynamic recipient placeholder. Anyone with
access to this link can sign the document, and it will then appear on your
documents page.
</Trans>
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
{team?.id ? <Trans>Team Only</Trans> : <Trans>Private</Trans>}
</h2>
<p>
{team?.id ? (
<Trans>
Team only templates are not linked anywhere and are visible only to your
team.
</Trans>
) : (
<Trans>Private templates can only be modified and viewed by you.</Trans>
)}
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
<TemplateType type={row.original.type} />
{row.original.directLink?.token && (
<TemplateDirectLinkBadge
className="ml-2"
token={row.original.directLink.token}
enabled={row.original.directLink.enabled}
/>
)}
</div>
),
},
{
header: _(msg`Actions`),
accessorKey: 'actions',
cell: ({ row }) => {
return (
<div className="flex items-center gap-x-4">
<TemplateUseDialog
templateId={row.original.id}
templateSigningOrder={row.original.templateMeta?.signingOrder}
documentDistributionMethod={row.original.templateMeta?.distributionMethod}
recipients={row.original.recipients}
documentRootPath={documentRootPath}
/>
<TemplatesTableActionDropdown
row={row.original}
teamId={team?.id}
templateRootPath={templateRootPath}
/>
</div>
);
},
},
] satisfies DataTableColumnDef<TemplatesTableRow>[];
}, [documentRootPath, team?.id, templateRootPath]);
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<div className="relative">
{remaining.documents === 0 && (
<Alert variant="warning" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
<Trans>Document Limit Exceeded!</Trans>
</AlertTitle>
<AlertDescription className="mt-2">
<Trans>
You have reached your document limit.{' '}
<Link className="underline underline-offset-4" to="/settings/billing">
Upgrade your account to continue!
</Link>
</Trans>
</AlertDescription>
</Alert>
)}
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError || false,
}}
skeleton={{
enable: isLoading || false,
rows: 5,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-40 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell className="py-4">
<div className="flex w-full flex-row items-center">
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-full" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-10 w-24 rounded" />
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<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>
);
};

View File

@ -1,29 +1,25 @@
import { Outlet } from 'react-router';
import { redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { SessionProvider } from '@documenso/lib/client-only/providers/session';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { AuthProvider } from '~/providers/auth';
import type { Route } from './+types/_layout';
export const loader = async ({ request }: Route.LoaderArgs) => {
const { session, user, isAuthenticated } = await getSession(request);
export const loader = ({ context }: Route.LoaderArgs) => {
const { session } = context;
if (!isAuthenticated) {
if (!session) {
return redirect('/signin');
}
const teams = await getTeams({ userId: user.id });
return {
user,
session,
teams,
user: session.user,
session: session.session,
teams: session.teams,
};
};
@ -31,7 +27,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
const { user, session, teams } = loaderData;
return (
<AuthProvider session={session} user={user}>
<SessionProvider session={session} user={user}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
@ -44,6 +40,6 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
<Outlet />
</main>
</LimitsProvider>
</AuthProvider>
</SessionProvider>
);
}

View File

@ -1,16 +1,16 @@
import { Trans } from '@lingui/macro';
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import type { Route } from './+types/_layout';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
export function loader({ context }: Route.LoaderArgs) {
const { user } = getRequiredSessionContext(context);
if (!user || !isAdmin(user)) {
return redirect('/documents');

View File

@ -2,7 +2,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { Link, redirect } from 'react-router';
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
import { trpc } from '@documenso/trpc/react';
@ -30,10 +30,13 @@ import type { Route } from './+types/documents.$id';
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.id);
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
// Todo: Is it possible for this to return data to the frontend w/out auth layout due to race condition?
// if (isNaN(id)) {
// return redirect('/admin/documents');
// }
if (isNaN(id)) {
return redirect('/admin/documents');
}
const document = await getEntireDocument({ id });

View File

@ -29,6 +29,10 @@ import { SettingsHeader } from '~/components/(dashboard)/settings/layout/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),
@ -37,10 +41,6 @@ export async function loader() {
return { banner };
}
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
const { banner } = loaderData;

View File

@ -4,9 +4,10 @@ import { DocumentStatus } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
@ -14,7 +15,6 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -33,26 +33,14 @@ import { DocumentPageViewDropdown } from '~/components/pages/document/document-p
import { DocumentPageViewInformation } from '~/components/pages/document/document-page-view-information';
import { DocumentPageViewRecentActivity } from '~/components/pages/document/document-page-view-recent-activity';
import { DocumentPageViewRecipients } from '~/components/pages/document/document-page-view-recipients';
import { useAuth } from '~/providers/auth';
import type { Route } from './+types/$id._index';
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ params, context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { id } = params;
const { user } = await getRequiredSession(request);
// Todo: Get from parent loader, this is just for testing.
const team = await prisma.team.findFirst({
where: {
documents: {
some: {
id: Number(id),
},
},
},
});
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);
@ -142,7 +130,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
export default function DocumentPage({ loaderData }: Route.ComponentProps) {
const { _ } = useLingui();
const { user } = useAuth();
const { user } = useSession();
const { document, documentRootPath, fields } = loaderData;

View File

@ -3,16 +3,15 @@ import { TeamMemberRole } from '@prisma/client';
import { DocumentStatus as InternalDocumentStatus } from '@prisma/client';
import { ChevronLeft, Users2 } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status';
@ -20,22 +19,11 @@ import { DocumentEditForm } from '~/components/pages/document/document-edit-form
import type { Route } from './+types/$id.edit';
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ params, context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { id } = params;
const { user } = await getRequiredSession(request);
// Todo: Get from parent loader, this is just for testing.
const team = await prisma.team.findFirst({
where: {
documents: {
some: {
id: Number(id),
},
},
},
});
const documentId = Number(id);
const documentRootPath = formatDocumentsPath(team?.url);

View File

@ -5,6 +5,7 @@ import type { Recipient } from '@prisma/client';
import { ChevronLeft } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
@ -23,21 +24,10 @@ import { DocumentLogsTable } from '~/components/tables/document-logs-table';
import type { Route } from './+types/$id.logs';
export async function loader({ request, params }: Route.LoaderArgs) {
export async function loader({ params, context }: Route.LoaderArgs) {
const { id } = params;
const { user } = await getRequiredSession(request);
// Todo: Get from parent loader, this is just for testing.
const team = await prisma.team.findFirst({
where: {
documents: {
some: {
id: Number(id),
},
},
},
});
const { user, currentTeam: team } = getRequiredSessionContext(context);
const documentId = Number(id);

View File

@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro';
import { useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@ -20,7 +21,6 @@ import { UpcomingProfileClaimTeaser } from '~/components/general/upcoming-profil
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 { useAuth } from '~/providers/auth';
import { useOptionalCurrentTeam } from '~/providers/team';
export function meta() {
@ -39,7 +39,7 @@ export function meta() {
export default function DocumentsPage() {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const { user } = useSession();
const team = useOptionalCurrentTeam();
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';

View File

@ -4,8 +4,9 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { TemplateDirectLink } from '@prisma/client';
import { TemplateType } from '@prisma/client';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredSession } 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';
@ -16,10 +17,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
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 { ManagePublicTemplateDialog } from '~/components/templates/manage-public-template-dialog';
import { useAuth } from '~/providers/auth';
import { useOptionalCurrentTeam } from '~/providers/team';
import { SettingsPublicProfileTemplatesTable } from '../../../../components/tables/settings-public-profile-templates-table';
@ -43,8 +43,8 @@ const teamProfileText = {
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 getRequiredSession(request); // Todo: Pull from...
export async function loader({ context }: Route.LoaderArgs) {
const { user } = getRequiredSessionContext(context);
const { profile } = await getUserPublicProfile({
userId: user.id,
@ -59,7 +59,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
const { _ } = useLingui();
const { toast } = useToast();
const user = useAuth();
const user = useSession();
const team = useOptionalCurrentTeam();
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);

View File

@ -2,6 +2,7 @@ import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Link } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@ -10,7 +11,6 @@ import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-au
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 { useAuth } from '~/providers/auth';
export function meta() {
return [{ title: 'Security' }];
@ -18,7 +18,7 @@ export function meta() {
export default function SettingsSecurity() {
const { _ } = useLingui();
const { user } = useAuth();
const { user } = useSession();
return (
<div>

View File

@ -1,8 +1,8 @@
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
import { Button } from '@documenso/ui/primitives/button';
@ -11,9 +11,8 @@ import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/index';
export async function loader({ request }: Route.LoaderArgs) {
// Todo: Make better
const { user } = await getRequiredSession(request);
export async function loader({ context }: Route.LoaderArgs) {
const { user } = getRequiredSessionContext(context);
// Todo: Use TRPC & use table instead
const tokens = await getUserTokens({ userId: user.id });

View File

@ -24,7 +24,7 @@ import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { TriggerMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
import { WebhookMultiSelectCombobox } from '~/components/general/webhook-multiselect-combobox';
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
@ -158,7 +158,7 @@ export default function WebhookPage() {
<Trans>Triggers</Trans>
</FormLabel>
<FormControl>
<TriggerMultiSelectCombobox
<WebhookMultiSelectCombobox
listValues={value}
onChange={(values: string[]) => {
onChange(values);

View File

@ -11,8 +11,8 @@ import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { CreateWebhookDialog } from '~/components/dialogs/webhook-create-dialog';
import { DeleteWebhookDialog } from '~/components/dialogs/webhook-delete-dialog';
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
export default function WebhookPage() {
const { _, i18n } = useLingui();
@ -25,7 +25,7 @@ export default function WebhookPage() {
title={_(msg`Webhooks`)}
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
>
<CreateWebhookDialog />
<WebhookCreateDialog />
</SettingsHeader>
{isLoading && (
@ -92,11 +92,11 @@ export default function WebhookPage() {
<Trans>Edit</Trans>
</Link>
</Button>
<DeleteWebhookDialog webhook={webhook}>
<WebhookDeleteDialog webhook={webhook}>
<Button variant="destructive">
<Trans>Delete</Trans>
</Button>
</DeleteWebhookDialog>
</WebhookDeleteDialog>
</div>
</div>
</div>

View File

@ -2,13 +2,11 @@ import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { ChevronLeft } from 'lucide-react';
import { Link, Outlet, isRouteErrorResponse, replace, useNavigate } from 'react-router';
import { Link, Outlet, isRouteErrorResponse, redirect, useNavigate } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { match } from 'ts-pattern';
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppErrorCode } from '@documenso/lib/errors/app-error';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
import { TrpcProvider } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@ -16,43 +14,28 @@ import { TeamProvider } from '~/providers/team';
import type { Route } from './+types/_layout';
export const loader = async ({ request, params }: Route.LoaderArgs) => {
// Todo: get user better from context or something
// Todo: get user better from context or something
const { user } = await getRequiredSession(request);
export const loader = ({ context }: Route.LoaderArgs) => {
const { currentTeam } = getRequiredSessionContext(context);
const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
getTeams({ userId: user.id }),
getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl }),
]);
console.log('1');
console.log({ userId: user.id, teamUrl: params.teamUrl });
console.log(getTeamPromise.status);
if (getTeamPromise.status === 'rejected') {
console.log('2');
return replace('/documents');
if (!currentTeam) {
return redirect('/documents');
}
const team = getTeamPromise.value;
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
const trpcHeaders = {
'x-team-Id': team.id.toString(),
'x-team-Id': currentTeam.id.toString(),
};
return {
team,
teams,
currentTeam,
trpcHeaders,
};
};
export default function Layout({ loaderData }: Route.ComponentProps) {
const { team, trpcHeaders } = loaderData;
const { currentTeam, trpcHeaders } = loaderData;
return (
<TeamProvider team={team}>
<TeamProvider team={currentTeam}>
<TrpcProvider headers={trpcHeaders}>
{/* Todo: Do this. */}
{/* {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (

View File

@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro';
import { CheckCircle2, Clock } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
@ -16,11 +17,10 @@ import { TeamTransferDialog } from '~/components/dialogs/team-transfer-dialog';
import { AvatarImageForm } from '~/components/forms/avatar-image';
import { TeamEmailDropdown } from '~/components/pages/teams/team-email-dropdown';
import { TeamTransferStatus } from '~/components/pages/teams/team-transfer-status';
import { useAuth } from '~/providers/auth';
import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsPage() {
const { user } = useAuth();
const { user } = useSession();
const team = useCurrentTeam();

View File

@ -1,9 +1,7 @@
import { Trans } from '@lingui/macro';
import { Outlet } from 'react-router';
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { TeamSettingsDesktopNav } from '~/components/pages/teams/team-settings-desktop-nav';
@ -11,26 +9,12 @@ import { TeamSettingsMobileNav } from '~/components/pages/teams/team-settings-mo
import type { Route } from '../+types/_layout';
export async function loader({ request, params }: Route.LoaderArgs) {
// Todo: Get from parent loaders...
const { user } = await getRequiredSession(request);
const teamUrl = params.teamUrl;
export async function loader({ context }: Route.LoaderArgs) {
const { currentTeam: team } = getRequiredTeamSessionContext(context);
try {
const team = await getTeamByUrl({ userId: user.id, teamUrl });
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
// Unauthorized.
throw new Response(null, { status: 401 }); // Todo: Test
}
} catch (e) {
const error = AppError.parseError(e);
if (error.code === AppErrorCode.NOT_FOUND) {
throw new Response(null, { status: 404 }); // Todo: Test
}
throw e;
// Todo: Test that 404 page shows up from error.
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
throw new Response(null, { status: 401 }); // Unauthorized.
}
}

View File

@ -1,12 +1,11 @@
import { Plural, Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
import type Stripe from 'stripe';
import { match } from 'ts-pattern';
import { getRequiredSession } 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';
@ -16,10 +15,8 @@ import { TeamBillingPortalButton } from '~/components/pages/teams/team-billing-p
import type { Route } from './+types/billing';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getRequiredSession(request);
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
export async function loader({ context }: Route.LoaderArgs) {
const { currentTeam: team } = getRequiredTeamSessionContext(context);
let teamSubscription: Stripe.Subscription | null = null;

View File

@ -1,14 +1,13 @@
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
import { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile+/index';
import type { Route } from './+types/public-profile';
export async function loader({ request }: Route.LoaderArgs) {
// Todo: Pull from...
const team = { id: 1 };
const { user } = await getRequiredSession(request);
export async function loader({ context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
const { profile } = await getTeamPublicProfile({
userId: user.id,
@ -20,4 +19,5 @@ export async function loader({ request }: Route.LoaderArgs) {
};
}
// Todo: Test that the profile shows up correctly for teams.
export default PublicProfilePage;

View File

@ -1,11 +1,10 @@
import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
@ -13,11 +12,8 @@ import { ApiTokenForm } from '~/components/forms/token';
import type { Route } from './+types/tokens';
export async function loader({ request, params }: Route.LoaderArgs) {
const { user } = await getRequiredSession(request); // Todo
// Todo
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
export async function loader({ context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);

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,209 @@
import { Trans } from '@lingui/macro';
import { DocumentSigningOrder, SigningStatus, type Team } from '@prisma/client';
import { ChevronLeft, LucideEdit } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { useSession } from '@documenso/lib/client-only/providers/session';
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 { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { TemplateDirectLinkDialogWrapper } from '~/components/dialogs/template-direct-link-dialog-wrapper';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
import { TemplateType } from '~/components/formatter/template-type';
import { TemplateDirectLinkBadge } from '~/components/pages/template/template-direct-link-badge';
import { TemplatePageViewDocumentsTable } from '~/components/pages/template/template-page-view-documents-table';
import { TemplatePageViewInformation } from '~/components/pages/template/template-page-view-information';
import { TemplatePageViewRecentActivity } from '~/components/pages/template/template-page-view-recent-activity';
import { TemplatePageViewRecipients } from '~/components/pages/template/template-page-view-recipients';
import { TemplatesTableActionDropdown } from '~/components/tables/templates-table-action-dropdown';
import { useOptionalCurrentTeam } from '~/providers/team';
import type { Route } from './+types/$id._index';
export async function loader({ params, context }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
const documentRootPath = formatDocumentsPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
return redirect(templateRootPath);
}
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
return redirect(templateRootPath);
}
return {
user,
team,
template,
templateRootPath,
documentRootPath,
};
}
export default function TemplatePage({ loaderData }: Route.ComponentProps) {
const { user, team, template, templateRootPath, documentRootPath } = loaderData;
const { templateDocumentData, fields, recipients, templateMeta } = template;
// 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} />
<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">
<LazyPDFViewer
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}
/>
</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,99 @@
import { Trans } from '@lingui/macro';
import { ChevronLeft } from 'lucide-react';
import { Link, redirect } from 'react-router';
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateType } from '~/components/formatter/template-type';
import { TemplateDirectLinkBadge } from '~/components/pages/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/pages/template/template-edit-form';
import { TemplateDirectLinkDialogWrapper } from '../../../components/dialogs/template-direct-link-dialog-wrapper';
import type { Route } from './+types/$id.edit';
export async function loader({ context, params }: Route.LoaderArgs) {
const { user, currentTeam: team } = getRequiredSessionContext(context);
const { id } = params;
const templateId = Number(id);
const templateRootPath = formatTemplatesPath(team?.url);
if (!templateId || Number.isNaN(templateId)) {
return redirect(templateRootPath);
}
const template = await getTemplateById({
id: templateId,
userId: user.id,
teamId: team?.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
return redirect(templateRootPath);
}
const isTemplateEnterprise = await isUserEnterprise({
userId: user.id,
teamId: team?.id,
});
return {
template,
isTemplateEnterprise,
templateRootPath,
};
}
export default function TemplateEditPage({ loaderData }: Route.ComponentProps) {
const { template, isTemplateEnterprise, templateRootPath } = loaderData;
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,88 @@
import { Trans } from '@lingui/macro';
import { Bird } from 'lucide-react';
import { useSearchParams } from 'react-router';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
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';
export function meta() {
return [{ title: '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 } = trpc.template.findTemplates.useQuery({
page: page,
perPage: perPage,
});
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={`${NEXT_PUBLIC_WEBAPP_URL()}/api/avatar/${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>
);
}

View File

@ -1,13 +1,9 @@
import { redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import type { Route } from './+types/_index';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
if (user) {
export async function loader({ context }: Route.LoaderArgs) {
if (context.session) {
return redirect('/documents');
}

View File

@ -1,7 +1,6 @@
import { Trans } from '@lingui/macro';
import { Link, redirect } from 'react-router';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import {
IS_GOOGLE_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
@ -17,10 +16,8 @@ export function meta(_args: Route.MetaArgs) {
return [{ title: 'Sign In' }];
}
export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request);
if (session.isAuthenticated) {
export async function loader({ context }: Route.LoaderArgs) {
if (context.session) {
return redirect('/documents');
}
}

View File

@ -5,6 +5,7 @@
"scripts": {
"build": "cross-env NODE_ENV=production react-router build",
"dev": "react-router dev",
"dev:bun": "bunx --bun vite",
"start": "cross-env NODE_ENV=production node dist/server/index.js",
"clean": "rimraf .react-router && rimraf node_modules",
"typecheck": "react-router typegen && tsc",

View File

@ -1,8 +1,13 @@
// server/index.ts
import { Hono } from 'hono';
import { PDFDocument } from 'pdf-lib';
import { auth } from '@documenso/auth/server';
import { AppError } from '@documenso/lib/errors/app-error';
import { jobsClient } from '@documenso/lib/jobs/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { getPresignGetUrl } from '@documenso/lib/universal/upload/server-actions';
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
@ -18,4 +23,69 @@ app.use('/api/v1/*', reactRouterTrpcServer); // Todo: ts-rest
app.use('/api/v2/*', async (c) => openApiTrpcServerHandler(c));
app.use('/api/trpc/*', reactRouterTrpcServer);
// Temp uploader.
app
.post('/api/file', async (c) => {
try {
const formData = await c.req.formData();
const file = formData.get('file') as File;
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Add file size validation
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (file.size > MAX_FILE_SIZE) {
return c.json({ error: 'File too large' }, 400);
}
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
if (pdf.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE');
}
if (!file.name.endsWith('.pdf')) {
file.name = `${file.name}.pdf`;
}
const { type, data } = await putFile(file);
const result = await createDocumentData({ type, data });
return c.json(result);
} catch (error) {
console.error('Upload failed:', error);
return c.json({ error: 'Upload failed' }, 500);
}
})
.get('/api/file', async (c) => {
const key = c.req.query('key');
const { url } = await getPresignGetUrl(key || '');
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
throw new Error(`Failed to get file "${key}", failed with status code ${response.status}`);
}
const buffer = await response.arrayBuffer();
const binaryData = new Uint8Array(buffer);
return c.json({
binaryData,
});
});
export default app;

View File

@ -1,45 +1,81 @@
// import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { type TGetTeamByUrlResponse, getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { type TGetTeamsResponse, getTeams } from '@documenso/lib/server-only/team/get-teams';
type GetLoadContextArgs = {
request: Request;
};
declare module 'react-router' {
interface AppLoadContext extends Awaited<ReturnType<typeof getLoadContext>> {
session: any;
url: string;
extra: string;
}
interface AppLoadContext extends Awaited<ReturnType<typeof getLoadContext>> {}
}
export async function getLoadContext(args: GetLoadContextArgs) {
console.log('-----------------');
console.log(args.request.url);
const initTime = Date.now();
const url = new URL(args.request.url);
console.log(url.pathname);
console.log(args.request.headers);
const request = args.request;
const url = new URL(request.url);
// Todo only make available for get requests (loaders) and non api routes
// use config
if (request.method !== 'GET' || !config.matcher.test(url.pathname)) {
console.log('[Session]: Pathname ignored', url.pathname);
return {
session: null,
};
}
const splitUrl = url.pathname.split('/');
// let team: TGetTeamByUrlResponse | null = null;
let team: TGetTeamByUrlResponse | null = null;
const session = await getSession(args.request);
// if (session.isAuthenticated && splitUrl[1] === 't' && splitUrl[2]) {
// const teamUrl = splitUrl[2];
if (session.isAuthenticated && splitUrl[1] === 't' && splitUrl[2]) {
const teamUrl = splitUrl[2];
// team = await getTeamByUrl({ userId: session.user.id, teamUrl });
// }
team = await getTeamByUrl({ userId: session.user.id, teamUrl });
}
let teams: TGetTeamsResponse = [];
if (session.isAuthenticated) {
// This is always loaded for the header.
teams = await getTeams({ userId: session.user.id });
}
const endTime = Date.now();
console.log(`[Session]: Pathname accepted in ${endTime - initTime}ms`, url.pathname);
// Todo: Optimise and chain promises.
// Todo: This is server only right?? Results not exposed?
return {
session: {
...session,
// currentUser:
// currentTeam: team,
},
url: args.request.url,
extra: 'stuff',
session: session.isAuthenticated
? {
session: session.session,
user: session.user,
currentTeam: team,
teams,
}
: null,
};
}
/**
* Route matcher configuration that excludes common non-route paths:
* - /api/* (API routes)
* - /assets/* (Static assets)
* - /build/* (Build output)
* - /favicon.* (Favicon files)
* - *.webmanifest (Web manifest files)
* - Paths starting with . (e.g. .well-known)
*
* The regex pattern (?!pattern) is a negative lookahead that ensures the path does NOT match any of these patterns.
* The .* at the end matches any remaining characters in the path.
*/
const config = {
matcher: new RegExp(
'/((?!api|assets|static|build|favicon|__manifest|site.webmanifest|manifest.webmanifest|\\..*).*)',
),
};

View File

@ -0,0 +1,31 @@
import type { AppLoadContext } from 'react-router';
import { redirect } from 'react-router';
/**
* Returns the session context or throws a redirect to signin if it is not present.
*/
export const getRequiredSessionContext = (context: AppLoadContext) => {
if (!context.session) {
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
}
return context.session;
};
/**
* Returns the team session context or throws a redirect to signin if it is not present.
*/
export const getRequiredTeamSessionContext = (context: AppLoadContext) => {
if (!context.session) {
throw redirect('/signin'); // Todo: Maybe add a redirect cookie to come back?
}
if (!context.session.currentTeam) {
throw new Response(null, { status: 404 }); // Todo: Test that 404 page shows up.
}
return {
...context.session,
currentTeam: context.session.currentTeam,
};
};

View File

@ -9,7 +9,7 @@ import { authDebugger } from '../utils/debugger';
* @param c - The Hono context.
*/
export const getSessionCookie = async (c: Context) => {
const sessionId = await getSignedCookie(c, 'secret', 'sessionId');
const sessionId = await getSignedCookie(c, 'secret', 'sessionId'); // Todo: Use secret
return sessionId;
};

View File

@ -3,6 +3,6 @@ import { env } from '@documenso/lib/utils/env';
// Todo: Delete
export const authDebugger = (message: string) => {
if (env('NODE_ENV') === 'development') {
console.log(`[DEBUG]: ${message}`);
// console.log(`[DEBUG]: ${message}`);
}
};

View File

@ -9,13 +9,13 @@ interface AuthProviderProps {
user: User;
}
const AuthContext = createContext<{
const SessionContext = createContext<{
user: User; // Todo: Exclude password
session: Session;
} | null>(null);
export const useAuth = () => {
const context = useContext(AuthContext);
export const useSession = () => {
const context = useContext(SessionContext);
if (!context) {
throw new Error('useAuth must be used within a AuthProvider');
@ -24,6 +24,6 @@ export const useAuth = () => {
return context;
};
export const AuthProvider = ({ children, session, user }: AuthProviderProps) => {
return <AuthContext.Provider value={{ session, user }}>{children}</AuthContext.Provider>;
export const SessionProvider = ({ children, session, user }: AuthProviderProps) => {
return <SessionContext.Provider value={{ session, user }}>{children}</SessionContext.Provider>;
};

View File

@ -1,5 +1,3 @@
'use client';
import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
@ -11,11 +9,11 @@ import type { Field, Recipient } from '@prisma/client';
import { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
@ -65,9 +63,7 @@ export const AddSignersFormPartial = ({
const { _ } = useLingui();
const { toast } = useToast();
const { remaining } = useLimits();
const { data: session } = useSession();
const user = session?.user;
const { user } = useSession();
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);

View File

@ -1,5 +1,3 @@
'use client';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Trans, msg } from '@lingui/macro';