fix: wip
This commit is contained in:
@@ -72,7 +72,7 @@ export const UpdateTeamForm = ({ teamId, teamName, teamUrl }: UpdateTeamDialogPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (url !== teamUrl) {
|
if (url !== teamUrl) {
|
||||||
void navigate(`${WEBAPP_BASE_URL}/t/${url}/settings`);
|
await navigate(`${WEBAPP_BASE_URL}/t/${url}/settings`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Trans, msg } from '@lingui/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
import { authClient } from '@documenso/auth/client';
|
import { authClient } from '@documenso/auth/client';
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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 { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
export type AccountDeleteDialogProps = {
|
export type AccountDeleteDialogProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) => {
|
export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useState } from 'react';
|
|||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import type { Document } from '@prisma/client';
|
import type { Document } from '@prisma/client';
|
||||||
import { useNavigation } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
@@ -28,7 +28,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const navigate = useNavigation();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export const AdminDocumentDeleteDialog = ({ document }: AdminDocumentDeleteDialo
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
void navigate('/admin/documents');
|
await navigate('/admin/documents');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`An unknown error occurred`),
|
title: _(msg`An unknown error occurred`),
|
||||||
|
|||||||
@@ -51,15 +51,14 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
||||||
trpcReact.document.duplicateDocument.useMutation({
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
onSuccess: ({ documentId }) => {
|
onSuccess: async ({ documentId }) => {
|
||||||
void navigate(`${documentsPath}/${documentId}/edit`);
|
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
description: _(msg`Your document has been successfully duplicated.`),
|
description: _(msg`Your document has been successfully duplicated.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await navigate(`${documentsPath}/${documentId}/edit`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { History } from 'lucide-react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
@@ -35,7 +36,6 @@ import {
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
const FORM_ID = 'resend-email';
|
const FORM_ID = 'resend-email';
|
||||||
@@ -56,7 +56,7 @@ export const ZResendDocumentFormSchema = z.object({
|
|||||||
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
||||||
|
|
||||||
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export const TeamCreateDialog = ({ trigger, ...props }: TeamCreateDialogProps) =
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
if (response.paymentRequired) {
|
if (response.paymentRequired) {
|
||||||
void navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
await navigate(`/settings/teams?tab=pending&checkout=${response.pendingTeamId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,9 +71,9 @@ export const TeamDeleteDialog = ({ trigger, teamId, teamName }: TeamDeleteDialog
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setOpen(false);
|
await navigate('/settings/teams');
|
||||||
|
|
||||||
void navigate('/settings/teams');
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = AppError.parseError(err);
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
|||||||
144
apps/remix/app/components/dialogs/template-create-dialog.tsx
Normal file
144
apps/remix/app/components/dialogs/template-create-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
86
apps/remix/app/components/dialogs/template-delete-dialog.tsx
Normal file
86
apps/remix/app/components/dialogs/template-delete-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
136
apps/remix/app/components/dialogs/template-move-dialog.tsx
Normal file
136
apps/remix/app/components/dialogs/template-move-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
587
apps/remix/app/components/dialogs/template-use-dialog.tsx
Normal file
587
apps/remix/app/components/dialogs/template-use-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
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 { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
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 { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
export type DocumentUploadDropzoneProps = {
|
export type DocumentUploadDropzoneProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
team?: {
|
team?: {
|
||||||
@@ -33,7 +32,7 @@ export const DocumentUploadDropzone = ({ className, team }: DocumentUploadDropzo
|
|||||||
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
TIME_ZONES.find((timezone) => timezone === Intl.DateTimeFormat().resolvedOptions().timeZone) ??
|
||||||
DEFAULT_DOCUMENT_TIME_ZONE;
|
DEFAULT_DOCUMENT_TIME_ZONE;
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -64,16 +63,29 @@ export const DocumentUploadDropzone = ({ className, team }: DocumentUploadDropzo
|
|||||||
// Todo
|
// Todo
|
||||||
// const { type, data } = await putPdfFile(file);
|
// 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({
|
// const { id: documentDataId } = await createDocumentData({
|
||||||
// type,
|
// type,
|
||||||
// data,
|
// data,
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// const { id } = await createDocument({
|
const { id } = await createDocument({
|
||||||
// title: file.name,
|
title: file.name,
|
||||||
// documentDataId,
|
documentDataId: response.id, // todo
|
||||||
// timezone: userTimezone,
|
timezone: userTimezone,
|
||||||
// });
|
});
|
||||||
|
|
||||||
void refreshLimits();
|
void refreshLimits();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
import { base64 } from '@documenso/lib/universal/base64';
|
||||||
@@ -26,7 +27,6 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export const ZAvatarImageFormSchema = z.object({
|
export const ZAvatarImageFormSchema = z.object({
|
||||||
@@ -40,7 +40,7 @@ export type AvatarImageFormProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
|
export const AvatarImageForm = ({ className }: AvatarImageFormProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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 { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
export const ZProfileFormSchema = z.object({
|
export const ZProfileFormSchema = z.object({
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
signature: z.string().min(1, 'Signature Pad cannot be empty'),
|
||||||
@@ -41,7 +40,7 @@ export type ProfileFormProps = {
|
|||||||
export const ProfileForm = ({ className }: ProfileFormProps) => {
|
export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const form = useForm<TProfileFormSchema>({
|
const form = useForm<TProfileFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const SearchParamSelector = ({ children, paramKey, isValueValid }: Search
|
|||||||
params.delete(paramKey);
|
params.delete(paramKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
void navigate(`${pathname}?${params.toString()}`, { scroll: false });
|
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export const SignInForm = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error.code === AuthenticationErrorCode.UnverifiedEmail) {
|
if (error.code === AuthenticationErrorCode.UnverifiedEmail) {
|
||||||
void navigate('/unverified-account');
|
await navigate('/unverified-account');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Unable to sign in`),
|
title: _(msg`Unable to sign in`),
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export const SignUpForm = ({
|
|||||||
try {
|
try {
|
||||||
await authClient.emailPassword.signUp({ name, email, password, signature, url });
|
await authClient.emailPassword.signUp({ name, email, password, signature, url });
|
||||||
|
|
||||||
void navigate(`/unverified-account`);
|
await navigate(`/unverified-account`);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Registration Successful`),
|
title: _(msg`Registration Successful`),
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type { Team, TeamGlobalSettings } from '@prisma/client';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
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 { trpc } from '@documenso/trpc/react';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -81,7 +77,8 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
|
|||||||
let uploadedBrandingLogo = settings?.brandingLogo;
|
let uploadedBrandingLogo = settings?.brandingLogo;
|
||||||
|
|
||||||
if (brandingLogo) {
|
if (brandingLogo) {
|
||||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
// Todo
|
||||||
|
// uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (brandingLogo === null) {
|
if (brandingLogo === null) {
|
||||||
@@ -118,13 +115,27 @@ export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPref
|
|||||||
const file = JSON.parse(settings.brandingLogo);
|
const file = JSON.parse(settings.brandingLogo);
|
||||||
|
|
||||||
if ('type' in file && 'data' in file) {
|
if ('type' in file && 'data' in file) {
|
||||||
void getFile(file).then((binaryData) => {
|
// Todo
|
||||||
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
// 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);
|
setPreviewUrl(objectUrl);
|
||||||
setHasLoadedPreview(true);
|
setHasLoadedPreview(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// void getFile(file).then((binaryData) => {
|
||||||
|
// const objectUrl = URL.createObjectURL(new Blob([binaryData]));
|
||||||
|
|
||||||
|
// setPreviewUrl(objectUrl);
|
||||||
|
// setHasLoadedPreview(true);
|
||||||
|
// });
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
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 { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import {
|
import {
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
SUPPORTED_LANGUAGE_CODES,
|
SUPPORTED_LANGUAGE_CODES,
|
||||||
isValidLanguageCode,
|
isValidLanguageCode,
|
||||||
} from '@documenso/lib/constants/i18n';
|
} 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 { trpc } from '@documenso/trpc/react';
|
||||||
import { Alert } from '@documenso/ui/primitives/alert';
|
import { Alert } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@@ -56,9 +54,9 @@ export const TeamDocumentPreferencesForm = ({
|
|||||||
}: TeamDocumentPreferencesFormProps) => {
|
}: TeamDocumentPreferencesFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
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 } =
|
const { mutateAsync: updateTeamDocumentPreferences } =
|
||||||
trpc.team.updateTeamDocumentSettings.useMutation();
|
trpc.team.updateTeamDocumentSettings.useMutation();
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/macro';
|
import { msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
|
import { ClaimPublicProfileDialogForm } from '~/components/forms/public-profile-claim-dialog';
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
export const UpcomingProfileClaimTeaser = () => {
|
export const UpcomingProfileClaimTeaser = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|||||||
@@ -294,18 +294,15 @@ export const DocumentEditForm = ({
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
void navigate(documentRootPath);
|
await navigate(documentRootPath);
|
||||||
return;
|
} else if (document.status === DocumentStatus.DRAFT) {
|
||||||
}
|
|
||||||
|
|
||||||
if (document.status === DocumentStatus.DRAFT) {
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Links Generated`),
|
title: _(msg`Links Generated`),
|
||||||
description: _(msg`Signing links have been generated for this document.`),
|
description: _(msg`Signing links have been generated for this document.`),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
void navigate(`${documentRootPath}/${document.id}`);
|
await navigate(`${documentRootPath}/${document.id}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ import { Link } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
export type DocumentPageViewButtonProps = {
|
export type DocumentPageViewButtonProps = {
|
||||||
document: Document & {
|
document: Document & {
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
@@ -23,7 +22,7 @@ export type DocumentPageViewButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
|
export const DocumentPageViewButton = ({ document }: DocumentPageViewButtonProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
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 { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentPageViewDropdownProps = {
|
export type DocumentPageViewDropdownProps = {
|
||||||
@@ -45,7 +45,7 @@ export type DocumentPageViewDropdownProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownProps) => {
|
export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
@@ -15,6 +11,7 @@ import {
|
|||||||
PenIcon,
|
PenIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
@@ -51,7 +48,7 @@ export const DocumentPageViewRecipients = ({
|
|||||||
|
|
||||||
{document.status !== DocumentStatus.COMPLETED && (
|
{document.status !== DocumentStatus.COMPLETED && (
|
||||||
<Link
|
<Link
|
||||||
href={`${documentRootPath}/${document.id}/edit?step=signers`}
|
to={`${documentRootPath}/${document.id}/edit?step=signers`}
|
||||||
title={_(msg`Modify recipients`)}
|
title={_(msg`Modify recipients`)}
|
||||||
className="flex flex-row items-center justify-between"
|
className="flex flex-row items-center justify-between"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
|
import type { TeamMemberRole, TeamTransferVerification } from '@prisma/client';
|
||||||
import { AnimatePresence } from 'framer-motion';
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
||||||
import type { TeamMemberRole, TeamTransferVerification } from '@documenso/prisma/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
294
apps/remix/app/components/pages/template/template-edit-form.tsx
Normal file
294
apps/remix/app/components/pages/template/template-edit-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/macro';
|
import { msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { DateTimeFormatOptions } from 'luxon';
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
@@ -32,7 +29,7 @@ const dateFormat: DateTimeFormatOptions = {
|
|||||||
export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
export const DocumentLogsTable = ({ documentId }: DocumentLogsTableProps) => {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { Link } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { useAuth } from '~/providers/auth';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentsTableActionButtonProps = {
|
export type DocumentsTableActionButtonProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@@ -20,14 +21,15 @@ export type DocumentsTableActionButtonProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
team?: Pick<Team, 'id' | 'url'>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsTableActionButton = ({ row, team }: DocumentsTableActionButtonProps) => {
|
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
const isOwner = row.user.id === user.id;
|
const isOwner = row.user.id === user.id;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
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 { DocumentMoveDialog } from '~/components/dialogs/document-move-dialog';
|
||||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||||
import { useAuth } from '~/providers/auth';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export type DocumentsTableActionDropdownProps = {
|
export type DocumentsTableActionDropdownProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@@ -45,11 +46,12 @@ export type DocumentsTableActionDropdownProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
team: Pick<Team, 'id' | 'url'> | null;
|
team: Pick<Team, 'id' | 'url'> | null;
|
||||||
};
|
};
|
||||||
team?: Pick<Team, 'id' | 'url'> & { teamEmail?: string };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentsTableActionDropdown = ({ row, team }: DocumentsTableActionDropdownProps) => {
|
export const DocumentsTableActionDropdown = ({ row }: DocumentsTableActionDropdownProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
@@ -229,7 +231,6 @@ export const DocumentsTableActionDropdown = ({ row, team }: DocumentsTableAction
|
|||||||
id={row.id}
|
id={row.id}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
team={team}
|
|
||||||
/>
|
/>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import type { Document, Recipient, Team, User } from '@prisma/client';
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
export type DataTableTitleProps = {
|
export type DataTableTitleProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
@@ -16,7 +15,7 @@ export type DataTableTitleProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Link } from 'react-router';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
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 { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
|
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 { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
import { DocumentsTableActionButton } from './documents-table-action-button';
|
import { DocumentsTableActionButton } from './documents-table-action-button';
|
||||||
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
|
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
|
||||||
@@ -86,8 +86,8 @@ export const DocumentsTable = ({
|
|||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
(!row.original.deletedAt || row.original.status === ExtendedDocumentStatus.COMPLETED) && (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<DocumentsTableActionButton team={team} row={row.original} />
|
<DocumentsTableActionButton row={row.original} />
|
||||||
<DocumentsTableActionDropdown team={team} row={row.original} />
|
<DocumentsTableActionDropdown row={row.original} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -169,7 +169,7 @@ type DataTableTitleProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
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 & {
|
type DirectTemplate = FindTemplateRow & {
|
||||||
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
directLink: Pick<TemplateDirectLink, 'token' | 'enabled'>;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
259
apps/remix/app/components/tables/templates-table.tsx
Normal file
259
apps/remix/app/components/tables/templates-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,29 +1,25 @@
|
|||||||
import { Outlet } from 'react-router';
|
import { Outlet } from 'react-router';
|
||||||
import { redirect } 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 { 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 { Header } from '~/components/(dashboard)/layout/header';
|
||||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
import { AuthProvider } from '~/providers/auth';
|
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
export const loader = ({ context }: Route.LoaderArgs) => {
|
||||||
const { session, user, isAuthenticated } = await getSession(request);
|
const { session } = context;
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!session) {
|
||||||
return redirect('/signin');
|
return redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const teams = await getTeams({ userId: user.id });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user: session.user,
|
||||||
session,
|
session: session.session,
|
||||||
teams,
|
teams: session.teams,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,7 +27,7 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
const { user, session, teams } = loaderData;
|
const { user, session, teams } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider session={session} user={user}>
|
<SessionProvider session={session} user={user}>
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
|
||||||
@@ -44,6 +40,6 @@ export default function Layout({ loaderData }: Route.ComponentProps) {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</LimitsProvider>
|
</LimitsProvider>
|
||||||
</AuthProvider>
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
|
import { BarChart3, FileStack, Settings, Trophy, Users, Wallet2 } from 'lucide-react';
|
||||||
import { Link, Outlet, redirect, useLocation } from 'react-router';
|
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 { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user } = await getSession(request);
|
const { user } = getRequiredSessionContext(context);
|
||||||
|
|
||||||
if (!user || !isAdmin(user)) {
|
if (!user || !isAdmin(user)) {
|
||||||
return redirect('/documents');
|
return redirect('/documents');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Trans, msg } from '@lingui/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { SigningStatus } from '@prisma/client';
|
import { SigningStatus } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
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 { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@@ -30,10 +30,13 @@ import type { Route } from './+types/documents.$id';
|
|||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const id = Number(params.id);
|
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)) {
|
if (isNaN(id)) {
|
||||||
// return redirect('/admin/documents');
|
return redirect('/admin/documents');
|
||||||
// }
|
}
|
||||||
|
|
||||||
const document = await getEntireDocument({ id });
|
const document = await getEntireDocument({ id });
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'
|
|||||||
|
|
||||||
import type { Route } from './+types/site-settings';
|
import type { Route } from './+types/site-settings';
|
||||||
|
|
||||||
|
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
||||||
|
|
||||||
|
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
const banner = await getSiteSettings().then((settings) =>
|
const banner = await getSiteSettings().then((settings) =>
|
||||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||||
@@ -37,10 +41,6 @@ export async function loader() {
|
|||||||
return { banner };
|
return { banner };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
|
|
||||||
|
|
||||||
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
|
|
||||||
|
|
||||||
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
export default function AdminBannerPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { banner } = loaderData;
|
const { banner } = loaderData;
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { DocumentStatus } from '@prisma/client';
|
|||||||
import { TeamMemberRole } from '@prisma/client';
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
import { ChevronLeft, Clock9, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
|
||||||
import { match } from 'ts-pattern';
|
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 { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
import { 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 { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
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 { DocumentPageViewInformation } from '~/components/pages/document/document-page-view-information';
|
||||||
import { DocumentPageViewRecentActivity } from '~/components/pages/document/document-page-view-recent-activity';
|
import { DocumentPageViewRecentActivity } from '~/components/pages/document/document-page-view-recent-activity';
|
||||||
import { DocumentPageViewRecipients } from '~/components/pages/document/document-page-view-recipients';
|
import { DocumentPageViewRecipients } from '~/components/pages/document/document-page-view-recipients';
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
import type { Route } from './+types/$id._index';
|
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 { 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 documentId = Number(id);
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team?.url);
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
@@ -142,7 +130,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
export default function DocumentPage({ loaderData }: Route.ComponentProps) {
|
export default function DocumentPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { document, documentRootPath, fields } = loaderData;
|
const { document, documentRootPath, fields } = loaderData;
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,15 @@ import { TeamMemberRole } from '@prisma/client';
|
|||||||
import { DocumentStatus as InternalDocumentStatus } from '@prisma/client';
|
import { DocumentStatus as InternalDocumentStatus } from '@prisma/client';
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
import { getRequiredSessionContext } from 'server/utils/get-required-session-context';
|
||||||
import { match } from 'ts-pattern';
|
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 { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
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';
|
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 { 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 documentId = Number(id);
|
||||||
|
|
||||||
const documentRootPath = formatDocumentsPath(team?.url);
|
const documentRootPath = formatDocumentsPath(team?.url);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Recipient } from '@prisma/client';
|
|||||||
import { ChevronLeft } from 'lucide-react';
|
import { ChevronLeft } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Link, redirect } from 'react-router';
|
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 { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
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';
|
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 { id } = params;
|
||||||
|
|
||||||
const { user } = await getRequiredSession(request);
|
const { user, currentTeam: team } = getRequiredSessionContext(context);
|
||||||
|
|
||||||
// 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 documentId = Number(id);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { Link } 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 { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||||
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
import { parseToIntegerArray } from '@documenso/lib/utils/params';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
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 { DocumentsTable } from '~/components/tables/documents-table';
|
||||||
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
|
||||||
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
@@ -39,7 +39,7 @@ export function meta() {
|
|||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { Trans, msg } from '@lingui/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import type { TemplateDirectLink } from '@prisma/client';
|
import type { TemplateDirectLink } from '@prisma/client';
|
||||||
import { TemplateType } 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 { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-public-profile';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { FindTemplateRow } from '@documenso/trpc/server/template-router/schema';
|
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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
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 type { TPublicProfileFormSchema } from '~/components/forms/public-profile-form';
|
||||||
import { PublicProfileForm } 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 { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { SettingsPublicProfileTemplatesTable } from '../../../../components/tables/settings-public-profile-templates-table';
|
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`,
|
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) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user } = await getRequiredSession(request); // Todo: Pull from...
|
const { user } = getRequiredSessionContext(context);
|
||||||
|
|
||||||
const { profile } = await getUserPublicProfile({
|
const { profile } = await getUserPublicProfile({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -59,7 +59,7 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const user = useAuth();
|
const user = useSession();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
const [isPublicProfileVisible, setIsPublicProfileVisible] = useState(profile.enabled);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Trans, msg } from '@lingui/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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 { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
|
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
|
|
||||||
export function meta() {
|
export function meta() {
|
||||||
return [{ title: 'Security' }];
|
return [{ title: 'Security' }];
|
||||||
@@ -18,7 +18,7 @@ export function meta() {
|
|||||||
|
|
||||||
export default function SettingsSecurity() {
|
export default function SettingsSecurity() {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
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 { getUserTokens } from '@documenso/lib/server-only/public-api/get-all-user-tokens';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@@ -11,9 +11,8 @@ import { ApiTokenForm } from '~/components/forms/token';
|
|||||||
|
|
||||||
import type { Route } from './+types/index';
|
import type { Route } from './+types/index';
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
// Todo: Make better
|
const { user } = getRequiredSessionContext(context);
|
||||||
const { user } = await getRequiredSession(request);
|
|
||||||
|
|
||||||
// Todo: Use TRPC & use table instead
|
// Todo: Use TRPC & use table instead
|
||||||
const tokens = await getUserTokens({ userId: user.id });
|
const tokens = await getUserTokens({ userId: user.id });
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { Switch } from '@documenso/ui/primitives/switch';
|
|||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
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 });
|
const ZEditWebhookFormSchema = ZEditWebhookMutationSchema.omit({ id: true });
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ export default function WebhookPage() {
|
|||||||
<Trans>Triggers</Trans>
|
<Trans>Triggers</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TriggerMultiSelectCombobox
|
<WebhookMultiSelectCombobox
|
||||||
listValues={value}
|
listValues={value}
|
||||||
onChange={(values: string[]) => {
|
onChange={(values: string[]) => {
|
||||||
onChange(values);
|
onChange(values);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { Badge } from '@documenso/ui/primitives/badge';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { CreateWebhookDialog } from '~/components/dialogs/webhook-create-dialog';
|
import { WebhookCreateDialog } from '~/components/dialogs/webhook-create-dialog';
|
||||||
import { DeleteWebhookDialog } from '~/components/dialogs/webhook-delete-dialog';
|
import { WebhookDeleteDialog } from '~/components/dialogs/webhook-delete-dialog';
|
||||||
|
|
||||||
export default function WebhookPage() {
|
export default function WebhookPage() {
|
||||||
const { _, i18n } = useLingui();
|
const { _, i18n } = useLingui();
|
||||||
@@ -25,7 +25,7 @@ export default function WebhookPage() {
|
|||||||
title={_(msg`Webhooks`)}
|
title={_(msg`Webhooks`)}
|
||||||
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
|
subtitle={_(msg`On this page, you can create new Webhooks and manage the existing ones.`)}
|
||||||
>
|
>
|
||||||
<CreateWebhookDialog />
|
<WebhookCreateDialog />
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
@@ -92,11 +92,11 @@ export default function WebhookPage() {
|
|||||||
<Trans>Edit</Trans>
|
<Trans>Edit</Trans>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<DeleteWebhookDialog webhook={webhook}>
|
<WebhookDeleteDialog webhook={webhook}>
|
||||||
<Button variant="destructive">
|
<Button variant="destructive">
|
||||||
<Trans>Delete</Trans>
|
<Trans>Delete</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</DeleteWebhookDialog>
|
</WebhookDeleteDialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import type { MessageDescriptor } from '@lingui/core';
|
|||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { ChevronLeft } from 'lucide-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 { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
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 { TrpcProvider } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
@@ -16,43 +14,28 @@ import { TeamProvider } from '~/providers/team';
|
|||||||
|
|
||||||
import type { Route } from './+types/_layout';
|
import type { Route } from './+types/_layout';
|
||||||
|
|
||||||
export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
export const loader = ({ context }: Route.LoaderArgs) => {
|
||||||
// Todo: get user better from context or something
|
const { currentTeam } = getRequiredSessionContext(context);
|
||||||
// Todo: get user better from context or something
|
|
||||||
const { user } = await getRequiredSession(request);
|
|
||||||
|
|
||||||
const [getTeamsPromise, getTeamPromise] = await Promise.allSettled([
|
if (!currentTeam) {
|
||||||
getTeams({ userId: user.id }),
|
return redirect('/documents');
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const team = getTeamPromise.value;
|
|
||||||
const teams = getTeamsPromise.status === 'fulfilled' ? getTeamsPromise.value : [];
|
|
||||||
|
|
||||||
const trpcHeaders = {
|
const trpcHeaders = {
|
||||||
'x-team-Id': team.id.toString(),
|
'x-team-Id': currentTeam.id.toString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
team,
|
currentTeam,
|
||||||
teams,
|
|
||||||
trpcHeaders,
|
trpcHeaders,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Layout({ loaderData }: Route.ComponentProps) {
|
export default function Layout({ loaderData }: Route.ComponentProps) {
|
||||||
const { team, trpcHeaders } = loaderData;
|
const { currentTeam, trpcHeaders } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TeamProvider team={team}>
|
<TeamProvider team={currentTeam}>
|
||||||
<TrpcProvider headers={trpcHeaders}>
|
<TrpcProvider headers={trpcHeaders}>
|
||||||
{/* Todo: Do this. */}
|
{/* Todo: Do this. */}
|
||||||
{/* {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
|
{/* {team.subscription && team.subscription.status !== SubscriptionStatus.ACTIVE && (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Trans } from '@lingui/macro';
|
|||||||
import { CheckCircle2, Clock } from 'lucide-react';
|
import { CheckCircle2, Clock } from 'lucide-react';
|
||||||
import { P, match } from 'ts-pattern';
|
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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { isTokenExpired } from '@documenso/lib/utils/token-verification';
|
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 { AvatarImageForm } from '~/components/forms/avatar-image';
|
||||||
import { TeamEmailDropdown } from '~/components/pages/teams/team-email-dropdown';
|
import { TeamEmailDropdown } from '~/components/pages/teams/team-email-dropdown';
|
||||||
import { TeamTransferStatus } from '~/components/pages/teams/team-transfer-status';
|
import { TeamTransferStatus } from '~/components/pages/teams/team-transfer-status';
|
||||||
import { useAuth } from '~/providers/auth';
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
export default function TeamsSettingsPage() {
|
export default function TeamsSettingsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useSession();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { Outlet } from 'react-router';
|
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 { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
import { TeamSettingsDesktopNav } from '~/components/pages/teams/team-settings-desktop-nav';
|
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';
|
import type { Route } from '../+types/_layout';
|
||||||
|
|
||||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
// Todo: Get from parent loaders...
|
const { currentTeam: team } = getRequiredTeamSessionContext(context);
|
||||||
const { user } = await getRequiredSession(request);
|
|
||||||
const teamUrl = params.teamUrl;
|
|
||||||
|
|
||||||
try {
|
// Todo: Test that 404 page shows up from error.
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamMember.role)) {
|
||||||
|
throw new Response(null, { status: 401 }); // Unauthorized.
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Plural, Trans, msg } from '@lingui/macro';
|
import { Plural, Trans, msg } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { getRequiredTeamSessionContext } from 'server/utils/get-required-session-context';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { getRequiredSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
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 { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
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';
|
import type { Route } from './+types/billing';
|
||||||
|
|
||||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user } = await getRequiredSession(request);
|
const { currentTeam: team } = getRequiredTeamSessionContext(context);
|
||||||
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
|
||||||
|
|
||||||
let teamSubscription: Stripe.Subscription | null = null;
|
let teamSubscription: Stripe.Subscription | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { getTeamPublicProfile } from '@documenso/lib/server-only/team/get-team-public-profile';
|
||||||
|
|
||||||
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile+/index';
|
import PublicProfilePage from '~/routes/_authenticated+/settings+/public-profile+/index';
|
||||||
|
|
||||||
import type { Route } from './+types/public-profile';
|
import type { Route } from './+types/public-profile';
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
// Todo: Pull from...
|
const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
|
||||||
const team = { id: 1 };
|
|
||||||
const { user } = await getRequiredSession(request);
|
|
||||||
|
|
||||||
const { profile } = await getTeamPublicProfile({
|
const { profile } = await getTeamPublicProfile({
|
||||||
userId: user.id,
|
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;
|
export default PublicProfilePage;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { DateTime } from 'luxon';
|
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 { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
|
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 { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
|
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';
|
import type { Route } from './+types/tokens';
|
||||||
|
|
||||||
export async function loader({ request, params }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user } = await getRequiredSession(request); // Todo
|
const { user, currentTeam: team } = getRequiredTeamSessionContext(context);
|
||||||
|
|
||||||
// Todo
|
|
||||||
const team = await getTeamByUrl({ userId: user.id, teamUrl: params.teamUrl });
|
|
||||||
|
|
||||||
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);
|
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id }).catch(() => null);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import TemplatePage, { loader } from '~/routes/_authenticated+/templates+/$id._index';
|
||||||
|
|
||||||
|
export { loader };
|
||||||
|
|
||||||
|
export default TemplatePage;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import TemplateEditPage, { loader } from '~/routes/_authenticated+/templates+/$id.edit';
|
||||||
|
|
||||||
|
export { loader };
|
||||||
|
|
||||||
|
export default TemplateEditPage;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import TemplatesPage, { meta } from '~/routes/_authenticated+/templates+/_index';
|
||||||
|
|
||||||
|
export { meta };
|
||||||
|
|
||||||
|
export default TemplatesPage;
|
||||||
209
apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx
Normal file
209
apps/remix/app/routes/_authenticated+/templates+/$id._index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
apps/remix/app/routes/_authenticated+/templates+/_index.tsx
Normal file
88
apps/remix/app/routes/_authenticated+/templates+/_index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
import { redirect } from 'react-router';
|
import { redirect } from 'react-router';
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
|
|
||||||
import type { Route } from './+types/_index';
|
import type { Route } from './+types/_index';
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const { user } = await getSession(request);
|
if (context.session) {
|
||||||
|
|
||||||
if (user) {
|
|
||||||
return redirect('/documents');
|
return redirect('/documents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans } from '@lingui/macro';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import {
|
import {
|
||||||
IS_GOOGLE_SSO_ENABLED,
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
@@ -17,10 +16,8 @@ export function meta(_args: Route.MetaArgs) {
|
|||||||
return [{ title: 'Sign In' }];
|
return [{ title: 'Sign In' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
const session = await getSession(request);
|
if (context.session) {
|
||||||
|
|
||||||
if (session.isAuthenticated) {
|
|
||||||
return redirect('/documents');
|
return redirect('/documents');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env NODE_ENV=production react-router build",
|
"build": "cross-env NODE_ENV=production react-router build",
|
||||||
"dev": "react-router dev",
|
"dev": "react-router dev",
|
||||||
|
"dev:bun": "bunx --bun vite",
|
||||||
"start": "cross-env NODE_ENV=production node dist/server/index.js",
|
"start": "cross-env NODE_ENV=production node dist/server/index.js",
|
||||||
"clean": "rimraf .react-router && rimraf node_modules",
|
"clean": "rimraf .react-router && rimraf node_modules",
|
||||||
"typecheck": "react-router typegen && tsc",
|
"typecheck": "react-router typegen && tsc",
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
// server/index.ts
|
// server/index.ts
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { PDFDocument } from 'pdf-lib';
|
||||||
|
|
||||||
import { auth } from '@documenso/auth/server';
|
import { auth } from '@documenso/auth/server';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
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 { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
||||||
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
|
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/v2/*', async (c) => openApiTrpcServerHandler(c));
|
||||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
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;
|
export default app;
|
||||||
|
|||||||
@@ -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 { 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 = {
|
type GetLoadContextArgs = {
|
||||||
request: Request;
|
request: Request;
|
||||||
};
|
};
|
||||||
|
|
||||||
declare module 'react-router' {
|
declare module 'react-router' {
|
||||||
interface AppLoadContext extends Awaited<ReturnType<typeof getLoadContext>> {
|
interface AppLoadContext extends Awaited<ReturnType<typeof getLoadContext>> {}
|
||||||
session: any;
|
|
||||||
url: string;
|
|
||||||
extra: string;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLoadContext(args: GetLoadContextArgs) {
|
export async function getLoadContext(args: GetLoadContextArgs) {
|
||||||
console.log('-----------------');
|
const initTime = Date.now();
|
||||||
console.log(args.request.url);
|
|
||||||
|
|
||||||
const url = new URL(args.request.url);
|
const request = args.request;
|
||||||
console.log(url.pathname);
|
const url = new URL(request.url);
|
||||||
console.log(args.request.headers);
|
|
||||||
|
// 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('/');
|
const splitUrl = url.pathname.split('/');
|
||||||
|
|
||||||
// let team: TGetTeamByUrlResponse | null = null;
|
let team: TGetTeamByUrlResponse | null = null;
|
||||||
|
|
||||||
const session = await getSession(args.request);
|
const session = await getSession(args.request);
|
||||||
|
|
||||||
// if (session.isAuthenticated && splitUrl[1] === 't' && splitUrl[2]) {
|
if (session.isAuthenticated && splitUrl[1] === 't' && splitUrl[2]) {
|
||||||
// const teamUrl = 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 {
|
return {
|
||||||
session: {
|
session: session.isAuthenticated
|
||||||
...session,
|
? {
|
||||||
// currentUser:
|
session: session.session,
|
||||||
// currentTeam: team,
|
user: session.user,
|
||||||
},
|
currentTeam: team,
|
||||||
url: args.request.url,
|
teams,
|
||||||
extra: 'stuff',
|
}
|
||||||
|
: 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|\\..*).*)',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|||||||
31
apps/remix/server/utils/get-required-session-context.ts
Normal file
31
apps/remix/server/utils/get-required-session-context.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ import { authDebugger } from '../utils/debugger';
|
|||||||
* @param c - The Hono context.
|
* @param c - The Hono context.
|
||||||
*/
|
*/
|
||||||
export const getSessionCookie = async (c: 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;
|
return sessionId;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { env } from '@documenso/lib/utils/env';
|
|||||||
// Todo: Delete
|
// Todo: Delete
|
||||||
export const authDebugger = (message: string) => {
|
export const authDebugger = (message: string) => {
|
||||||
if (env('NODE_ENV') === 'development') {
|
if (env('NODE_ENV') === 'development') {
|
||||||
console.log(`[DEBUG]: ${message}`);
|
// console.log(`[DEBUG]: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ interface AuthProviderProps {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<{
|
const SessionContext = createContext<{
|
||||||
user: User; // Todo: Exclude password
|
user: User; // Todo: Exclude password
|
||||||
session: Session;
|
session: Session;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useSession = () => {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(SessionContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useAuth must be used within a AuthProvider');
|
throw new Error('useAuth must be used within a AuthProvider');
|
||||||
@@ -24,6 +24,6 @@ export const useAuth = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AuthProvider = ({ children, session, user }: AuthProviderProps) => {
|
export const SessionProvider = ({ children, session, user }: AuthProviderProps) => {
|
||||||
return <AuthContext.Provider value={{ session, user }}>{children}</AuthContext.Provider>;
|
return <SessionContext.Provider value={{ session, user }}>{children}</SessionContext.Provider>;
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
|
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 { DocumentSigningOrder, RecipientRole, SendStatus } from '@prisma/client';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
|
import { GripVerticalIcon, Plus, Trash } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { prop, sortBy } from 'remeda';
|
import { prop, sortBy } from 'remeda';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
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 { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
|
||||||
@@ -65,9 +63,7 @@ export const AddSignersFormPartial = ({
|
|||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { remaining } = useLimits();
|
const { remaining } = useLimits();
|
||||||
const { data: session } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const user = session?.user;
|
|
||||||
|
|
||||||
const initialId = useId();
|
const initialId = useId();
|
||||||
const $sensorApi = useRef<SensorAPI | null>(null);
|
const $sensorApi = useRef<SensorAPI | null>(null);
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Trans, msg } from '@lingui/macro';
|
import { Trans, msg } from '@lingui/macro';
|
||||||
|
|||||||
Reference in New Issue
Block a user