mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 17:21:41 +10:00
Merge branch 'main' into staging
This commit is contained in:
@ -38,11 +38,17 @@ You will be prompted to enter some information, such as the certificate's Common
|
||||
Combine the private key and the self-signed certificate to create a `.p12` certificate. Use the following command:
|
||||
|
||||
```bash
|
||||
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt
|
||||
openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt -legacy
|
||||
```
|
||||
|
||||
<Callout type="warning">
|
||||
If you get the error "Error: Failed to get private key bags", add the `-legacy` flag to the command `openssl pkcs12 -export -out certificate.p12 -inkey private.key -in certificate.crt -legacy`.
|
||||
When running the application in Docker, you may encounter permission issues when attempting to sign documents using your certificate (.p12) file. This happens because the application runs as a non-root user inside the container and needs read access to the certificate.
|
||||
|
||||
To resolve this, you'll need to update the certificate file permissions to allow the container user 1001, which runs NextJS, to read it:
|
||||
|
||||
```bash
|
||||
sudo chown 1001 certificate.p12
|
||||
```
|
||||
|
||||
</Callout>
|
||||
|
||||
@ -54,8 +60,8 @@ Note that for local development, the password can be left empty.
|
||||
|
||||
### Add Certificate to the Project
|
||||
|
||||
Finally, add the certificate to the project. Place the `certificate.p12` file in the `/apps/web/resources` directory. If the directory doesn't exist, create it.
|
||||
Use the `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` environment variable to point at the certificate you created.
|
||||
|
||||
The final file path should be `/apps/web/resources/certificate.p12`.
|
||||
Details about environment variables associated with certificates can be found [here](/developers/self-hosting/signing-certificate#configure-documenso-to-use-the-certificate).
|
||||
|
||||
</Steps>
|
||||
|
||||
@ -133,7 +133,7 @@ volumes:
|
||||
After updating the volume binding, save the `compose.yml` file and run the following command to start the containers:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env -d up
|
||||
docker-compose --env-file ./.env up -d
|
||||
```
|
||||
|
||||
The command will start the PostgreSQL database and the Documenso application containers.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/marketing",
|
||||
"version": "1.7.2-rc.3",
|
||||
"version": "1.7.2-rc.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
23
apps/marketing/src/app/(marketing)/[content]/content.tsx
Normal file
23
apps/marketing/src/app/(marketing)/[content]/content.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import type { DocumentTypes } from 'contentlayer/generated';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
||||
<Image {...props} alt={props.alt ?? ''} />
|
||||
),
|
||||
};
|
||||
|
||||
export type ContentPageContentProps = {
|
||||
document: DocumentTypes;
|
||||
};
|
||||
|
||||
export const ContentPageContent = ({ document }: ContentPageContentProps) => {
|
||||
const MDXContent = useMDXComponent(document.body.code);
|
||||
|
||||
return <MDXContent components={mdxComponents} />;
|
||||
};
|
||||
@ -1,12 +1,11 @@
|
||||
import Image from 'next/image';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { allDocuments } from 'contentlayer/generated';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { ContentPageContent } from './content';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const generateMetadata = ({ params }: { params: { content: string } }) => {
|
||||
@ -19,12 +18,6 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
|
||||
return { title: document.title };
|
||||
};
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
||||
<Image {...props} alt={props.alt ?? ''} />
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* A generic catch all page for the root level that checks for content layer documents.
|
||||
*
|
||||
@ -39,11 +32,9 @@ export default async function ContentPage({ params }: { params: { content: strin
|
||||
notFound();
|
||||
}
|
||||
|
||||
const MDXContent = useMDXComponent(post.body.code);
|
||||
|
||||
return (
|
||||
<article className="prose dark:prose-invert mx-auto">
|
||||
<MDXContent components={mdxComponents} />
|
||||
<ContentPageContent document={post} />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
23
apps/marketing/src/app/(marketing)/blog/[post]/content.tsx
Normal file
23
apps/marketing/src/app/(marketing)/blog/[post]/content.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
|
||||
import type { BlogPost } from 'contentlayer/generated';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
||||
<Image {...props} alt={props.alt ?? ''} />
|
||||
),
|
||||
};
|
||||
|
||||
export type BlogPostContentProps = {
|
||||
post: BlogPost;
|
||||
};
|
||||
|
||||
export const BlogPostContent = ({ post }: BlogPostContentProps) => {
|
||||
const MdxContent = useMDXComponent(post.body.code);
|
||||
|
||||
return <MdxContent components={mdxComponents} />;
|
||||
};
|
||||
@ -1,16 +1,15 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { allBlogPosts } from 'contentlayer/generated';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
import { useMDXComponent } from 'next-contentlayer/hooks';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import { CallToAction } from '~/components/(marketing)/call-to-action';
|
||||
|
||||
import { BlogPostContent } from './content';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
@ -42,12 +41,6 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const mdxComponents: MDXComponents = {
|
||||
MdxNextImage: (props: { width: number; height: number; alt?: string; src: string }) => (
|
||||
<Image {...props} alt={props.alt ?? ''} />
|
||||
),
|
||||
};
|
||||
|
||||
export default async function BlogPostPage({ params }: { params: { post: string } }) {
|
||||
await setupI18nSSR();
|
||||
|
||||
@ -57,8 +50,6 @@ export default async function BlogPostPage({ params }: { params: { post: string
|
||||
notFound();
|
||||
}
|
||||
|
||||
const MDXContent = useMDXComponent(post.body.code);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<article className="prose dark:prose-invert mx-auto py-8">
|
||||
@ -87,7 +78,7 @@ export default async function BlogPostPage({ params }: { params: { post: string
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MDXContent components={mdxComponents} />
|
||||
<BlogPostContent post={post} />
|
||||
|
||||
{post.tags.length > 0 && (
|
||||
<ul className="not-prose flex list-none flex-row space-x-2 px-0">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
@ -130,9 +131,9 @@ const fetchEarlyAdopters = async () => {
|
||||
};
|
||||
|
||||
export default async function OpenPage() {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
await setupI18nSSR();
|
||||
|
||||
const { _ } = i18n;
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [
|
||||
{ forks_count: forksCount, stargazers_count: stargazersCount },
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@documenso/web",
|
||||
"version": "1.7.2-rc.3",
|
||||
"version": "1.7.2-rc.4",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@ -33,6 +33,8 @@ import {
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||
|
||||
import { ResendDocumentActionItem } from '../_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from '../delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from '../duplicate-document-dialog';
|
||||
@ -62,6 +64,7 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
|
||||
const isOwner = document.User.id === session.user.id;
|
||||
const isDraft = document.status === DocumentStatus.DRAFT;
|
||||
const isPending = document.status === DocumentStatus.PENDING;
|
||||
const isDeleted = document.deletedAt !== null;
|
||||
const isComplete = document.status === DocumentStatus.COMPLETED;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
@ -145,6 +148,21 @@ export const DocumentPageViewDropdown = ({ document, team }: DocumentPageViewDro
|
||||
<Trans>Share</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{canManageDocument && (
|
||||
<DocumentRecipientLinkCopyDialog
|
||||
recipients={document.Recipient}
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
disabled={!isPending || isDeleted}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Signing Links</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResendDocumentActionItem
|
||||
document={document}
|
||||
recipients={nonSignedRecipients}
|
||||
|
||||
@ -143,17 +143,11 @@ export const DocumentPageViewRecentActivity = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Todo: Translations. */}
|
||||
<p
|
||||
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
|
||||
title={`${formatDocumentAuditLogAction(auditLog, userId).prefix} ${
|
||||
formatDocumentAuditLogAction(auditLog, userId).description
|
||||
}`}
|
||||
title={formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
>
|
||||
<span className="text-foreground font-medium">
|
||||
{formatDocumentAuditLogAction(auditLog, userId).prefix}
|
||||
</span>{' '}
|
||||
{formatDocumentAuditLogAction(auditLog, userId).description}
|
||||
{formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
</p>
|
||||
|
||||
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
@ -6,11 +8,14 @@ import { CheckIcon, Clock, MailIcon, MailOpenIcon, PenIcon, PlusIcon } from 'luc
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentPageViewRecipientsProps = {
|
||||
document: Document & {
|
||||
@ -24,6 +29,7 @@ export const DocumentPageViewRecipients = ({
|
||||
documentRootPath,
|
||||
}: DocumentPageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const recipients = document.Recipient;
|
||||
|
||||
@ -68,53 +74,69 @@ export const DocumentPageViewRecipients = ({
|
||||
}
|
||||
/>
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||
<Badge variant="default">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Approved</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.CC, () =>
|
||||
document.status === DocumentStatus.COMPLETED ? (
|
||||
<>
|
||||
<MailIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Sent</Trans>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-row items-center">
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.SIGNED && (
|
||||
<Badge variant="default">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Ready</Trans>
|
||||
<Trans>Approved</Trans>
|
||||
</>
|
||||
),
|
||||
)
|
||||
))
|
||||
.with(RecipientRole.CC, () =>
|
||||
document.status === DocumentStatus.COMPLETED ? (
|
||||
<>
|
||||
<MailIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Sent</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Ready</Trans>
|
||||
</>
|
||||
),
|
||||
)
|
||||
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<>
|
||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Viewed</Trans>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Badge>
|
||||
)}
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
))
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<>
|
||||
<MailOpenIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Viewed</Trans>
|
||||
</>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{document.status !== DocumentStatus.DRAFT &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED && (
|
||||
<Badge variant="secondary">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{document.status === DocumentStatus.PENDING &&
|
||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||
recipient.role !== RecipientRole.CC && (
|
||||
<CopyTextButton
|
||||
value={formatSigningLink(recipient.token)}
|
||||
onCopySuccess={() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -26,6 +26,7 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
|
||||
import { DocumentHistorySheet } from '~/components/document/document-history-sheet';
|
||||
import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||
import {
|
||||
DocumentStatus as DocumentStatusComponent,
|
||||
FRIENDLY_STATUS_MAP,
|
||||
@ -134,6 +135,10 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
{document.status === DocumentStatus.PENDING && (
|
||||
<DocumentRecipientLinkCopyDialog recipients={recipients} />
|
||||
)}
|
||||
|
||||
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Documents</Trans>
|
||||
|
||||
@ -58,10 +58,6 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
||||
});
|
||||
};
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
@ -103,9 +99,7 @@ export const DocumentLogsDataTable = ({ documentId }: DocumentLogsDataTableProps
|
||||
{
|
||||
header: _(msg`Action`),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<span>{uppercaseFistLetter(formatDocumentAuditLogAction(row.original).description)}</span>
|
||||
),
|
||||
cell: ({ row }) => <span>{formatDocumentAuditLogAction(_, row.original).description}</span>,
|
||||
},
|
||||
{
|
||||
header: 'IP Address',
|
||||
|
||||
@ -37,6 +37,8 @@ import {
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/document/document-recipient-link-copy-dialog';
|
||||
|
||||
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||
import { DeleteDocumentDialog } from './delete-document-dialog';
|
||||
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||
@ -69,7 +71,7 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
const isOwner = row.User.id === session.user.id;
|
||||
// const isRecipient = !!recipient;
|
||||
const isDraft = row.status === DocumentStatus.DRAFT;
|
||||
// const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||
@ -191,6 +193,20 @@ export const DataTableActionDropdown = ({ row, team }: DataTableActionDropdownPr
|
||||
<Trans>Share</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{canManageDocument && (
|
||||
<DocumentRecipientLinkCopyDialog
|
||||
recipients={row.Recipient}
|
||||
trigger={
|
||||
<DropdownMenuItem disabled={!isPending} asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Signing Links</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} team={team} />
|
||||
|
||||
<DocumentShareButton
|
||||
|
||||
@ -44,11 +44,11 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
|
||||
const [checkoutSessionPriceId, setCheckoutSessionPriceId] = useState<string | null>(null);
|
||||
|
||||
const onSubscribeClick = async (priceId: string) => {
|
||||
try {
|
||||
setIsFetchingCheckoutSession(true);
|
||||
setCheckoutSessionPriceId(priceId);
|
||||
|
||||
const url = await createCheckout({ priceId });
|
||||
|
||||
@ -64,7 +64,7 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingCheckoutSession(false);
|
||||
setCheckoutSessionPriceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@ -122,7 +122,8 @@ export const BillingPlans = ({ prices }: BillingPlansProps) => {
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
loading={isFetchingCheckoutSession}
|
||||
disabled={checkoutSessionPriceId !== null}
|
||||
loading={checkoutSessionPriceId === price.id}
|
||||
onClick={() => void onSubscribeClick(price.id)}
|
||||
>
|
||||
<Trans>Subscribe</Trans>
|
||||
|
||||
14
apps/web/src/app/(dashboard)/templates/[id]/edit/page.tsx
Normal file
14
apps/web/src/app/(dashboard)/templates/[id]/edit/page.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import type { TemplateEditPageViewProps } from './template-edit-page-view';
|
||||
import { TemplateEditPageView } from './template-edit-page-view';
|
||||
|
||||
type TemplateEditPageProps = Pick<TemplateEditPageViewProps, 'params'>;
|
||||
|
||||
export default async function TemplateEditPage({ params }: TemplateEditPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
return <TemplateEditPageView params={params} />;
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { TemplateDirectLinkBadge } from '../../template-direct-link-badge';
|
||||
import { TemplateDirectLinkDialogWrapper } from '../template-direct-link-dialog-wrapper';
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
|
||||
export type TemplateEditPageViewProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplateEditPageView = async ({ params, team }: TemplateEditPageViewProps) => {
|
||||
const { id } = params;
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateWithDetailsById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const isTemplateEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
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
|
||||
href={`${templateRootPath}/${templateId}`}
|
||||
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 truncate text-2xl font-semibold 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>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-6"
|
||||
initialTemplate={template}
|
||||
templateRootPath={templateRootPath}
|
||||
isEnterprise={isTemplateEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,11 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
|
||||
import type { TemplatePageViewProps } from './template-page-view';
|
||||
import { TemplatePageView } from './template-page-view';
|
||||
|
||||
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
|
||||
export type TemplatePageProps = {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
@ -10,11 +10,13 @@ import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
export type TemplateDirectLinkDialogWrapperProps = {
|
||||
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
|
||||
export const TemplateDirectLinkDialogWrapper = ({
|
||||
template,
|
||||
}: TemplateDirectLinkDialogWrapperProps) => {
|
||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
|
||||
@ -0,0 +1,281 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@documenso/prisma/client';
|
||||
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 { DataTableActionButton } from '../../documents/data-table-action-button';
|
||||
import { DataTableActionDropdown } from '../../documents/data-table-action-dropdown';
|
||||
import { DataTableTitle } from '../../documents/data-table-title';
|
||||
|
||||
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
|
||||
DOCUMENT: msg`Document`,
|
||||
TEMPLATE: msg`Template`,
|
||||
TEMPLATE_DIRECT_LINK: msg`Direct link`,
|
||||
};
|
||||
|
||||
const ZTemplateSearchParamsSchema = ZBaseTableSearchParamsSchema.extend({
|
||||
source: z
|
||||
.nativeEnum(DocumentSource)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
status: z
|
||||
.nativeEnum(DocumentStatusEnum)
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
search: z.coerce
|
||||
.string()
|
||||
.optional()
|
||||
.catch(() => undefined),
|
||||
});
|
||||
|
||||
type TemplatePageViewDocumentsTableProps = {
|
||||
templateId: number;
|
||||
team?: Team;
|
||||
};
|
||||
|
||||
export const TemplatePageViewDocumentsTable = ({
|
||||
templateId,
|
||||
team,
|
||||
}: TemplatePageViewDocumentsTableProps) => {
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const parsedSearchParams = ZTemplateSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
);
|
||||
|
||||
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||
trpc.document.findDocuments.useQuery(
|
||||
{
|
||||
templateId,
|
||||
teamId: team?.id,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
search: parsedSearchParams.search,
|
||||
source: parsedSearchParams.source,
|
||||
status: parsedSearchParams.status,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
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.Recipient}
|
||||
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">
|
||||
<DataTableActionButton team={team} row={row.original} />
|
||||
|
||||
<DataTableActionDropdown team={team} 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 && isInitialLoading,
|
||||
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,66 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import type { Template, User } from '@documenso/prisma/client';
|
||||
|
||||
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,163 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentSource } from '@documenso/prisma/client';
|
||||
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;
|
||||
teamId?: number;
|
||||
documentRootPath: string;
|
||||
};
|
||||
|
||||
export const TemplatePageViewRecentActivity = ({
|
||||
templateId,
|
||||
teamId,
|
||||
documentRootPath,
|
||||
}: TemplatePageViewRecentActivityProps) => {
|
||||
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
|
||||
templateId,
|
||||
teamId,
|
||||
orderBy: {
|
||||
column: 'createdAt',
|
||||
direction: '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
|
||||
href={`${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,69 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { PenIcon, PlusIcon } from 'lucide-react';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import type { Recipient, Template } from '@documenso/prisma/client';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
export type TemplatePageViewRecipientsProps = {
|
||||
template: Template & {
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
templateRootPath: string;
|
||||
};
|
||||
|
||||
export const TemplatePageViewRecipients = ({
|
||||
template,
|
||||
templateRootPath,
|
||||
}: TemplatePageViewRecipientsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const recipients = template.Recipient;
|
||||
|
||||
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
|
||||
href={`${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,22 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { ChevronLeft, LucideEdit } from 'lucide-react';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTemplateWithDetailsById } from '@documenso/lib/server-only/template/get-template-with-details-by-id';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentSigningOrder, SigningStatus, type Team } from '@documenso/prisma/client';
|
||||
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 { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields';
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { DataTableActionDropdown } from '../data-table-action-dropdown';
|
||||
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
import { UseTemplateDialog } from '../use-template-dialog';
|
||||
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
|
||||
import { TemplatePageViewDocumentsTable } from './template-page-view-documents-table';
|
||||
import { TemplatePageViewInformation } from './template-page-view-information';
|
||||
import { TemplatePageViewRecentActivity } from './template-page-view-recent-activity';
|
||||
import { TemplatePageViewRecipients } from './template-page-view-recipients';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
params: {
|
||||
@ -30,6 +36,7 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
const templateId = Number(id);
|
||||
const templateRootPath = formatTemplatesPath(team?.url);
|
||||
const documentRootPath = formatDocumentsPath(team?.url);
|
||||
|
||||
if (!templateId || Number.isNaN(templateId)) {
|
||||
redirect(templateRootPath);
|
||||
@ -37,29 +44,51 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const template = await getTemplateWithDetailsById({
|
||||
const template = await getTemplateById({
|
||||
id: templateId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.templateDocumentData) {
|
||||
if (!template || !template.templateDocumentData || (template?.teamId && !team?.url)) {
|
||||
redirect(templateRootPath);
|
||||
}
|
||||
|
||||
const isTemplateEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
const { templateDocumentData, Field, Recipient: recipients, templateMeta } = template;
|
||||
|
||||
// Remap to fit the DocumentReadOnlyFields component.
|
||||
const readOnlyFields = Field.map((field) => {
|
||||
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
|
||||
name: '',
|
||||
email: '',
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
};
|
||||
|
||||
return {
|
||||
...field,
|
||||
Recipient: recipient,
|
||||
Signature: null,
|
||||
};
|
||||
});
|
||||
|
||||
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 href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Templates</Trans>
|
||||
</Link>
|
||||
const mockedDocumentMeta = templateMeta
|
||||
? {
|
||||
typedSignatureEnabled: false,
|
||||
...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 href={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 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
@ -77,17 +106,97 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 sm:mt-0 sm:self-end">
|
||||
<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 href={`${templateRootPath}/${template.id}/edit`}>
|
||||
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||
<Trans>Edit Template</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
className="mt-6"
|
||||
initialTemplate={template}
|
||||
templateRootPath={templateRootPath}
|
||||
isEnterprise={isTemplateEnterprise}
|
||||
/>
|
||||
<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>
|
||||
<DataTableActionDropdown
|
||||
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">
|
||||
<UseTemplateDialog
|
||||
templateId={template.id}
|
||||
templateSigningOrder={template.templateMeta?.signingOrder}
|
||||
recipients={template.Recipient}
|
||||
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}
|
||||
teamId={team?.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 team={team} templateId={template.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ import { Trans } from '@lingui/macro';
|
||||
import { Copy, Edit, MoreHorizontal, MoveRight, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -23,7 +23,10 @@ import { MoveTemplateDialog } from './move-template-dialog';
|
||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: FindTemplateRow;
|
||||
row: Template & {
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
templateRootPath: string;
|
||||
teamId?: number;
|
||||
};
|
||||
@ -57,7 +60,7 @@ export const DataTableActionDropdown = ({
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner && !isTeamTemplate} asChild>
|
||||
<Link href={`${templateRootPath}/${row.id}`}>
|
||||
<Link href={`${templateRootPath}/${row.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
|
||||
@ -124,7 +124,7 @@ export const TemplatesDataTable = ({
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-row items-center">
|
||||
<TemplateType type="PRIVATE" />
|
||||
<TemplateType type={row.original.type} />
|
||||
|
||||
{row.original.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
|
||||
@ -73,7 +73,7 @@ export const NewTemplateDialog = ({ teamId, templateRootPath }: NewTemplateDialo
|
||||
|
||||
setShowNewTemplateDialog(false);
|
||||
|
||||
router.push(`${templateRootPath}/${id}`);
|
||||
router.push(`${templateRootPath}/${id}/edit`);
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
@ -92,6 +94,7 @@ export type UseTemplateDialogProps = {
|
||||
templateSigningOrder?: DocumentSigningOrder | null;
|
||||
recipients: Recipient[];
|
||||
documentRootPath: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function UseTemplateDialog({
|
||||
@ -99,6 +102,7 @@ export function UseTemplateDialog({
|
||||
documentRootPath,
|
||||
templateId,
|
||||
templateSigningOrder,
|
||||
trigger,
|
||||
}: UseTemplateDialogProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@ -186,10 +190,12 @@ export function UseTemplateDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Use Template</Trans>
|
||||
</Button>
|
||||
{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>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
@ -25,7 +25,12 @@ const dateFormat: DateTimeFormatOptions = {
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*/
|
||||
export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const parser = new UAParser();
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
@ -36,11 +41,11 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Browser</TableHead>
|
||||
<TableHead>{_(msg`Time`)}</TableHead>
|
||||
<TableHead>{_(msg`User`)}</TableHead>
|
||||
<TableHead>{_(msg`Action`)}</TableHead>
|
||||
<TableHead>{_(msg`IP Address`)}</TableHead>
|
||||
<TableHead>{_(msg`Browser`)}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@ -74,7 +79,7 @@ export const AuditLogDataTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(log).description)}
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{log.ipAddress}</TableCell>
|
||||
|
||||
@ -2,13 +2,18 @@ import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION_ENG } from '@documenso/lib/constants/recipient-roles';
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { DOCUMENT_STATUS } from '@documenso/lib/constants/document';
|
||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import { Logo } from '~/components/branding/logo';
|
||||
@ -21,7 +26,17 @@ type AuditLogProps = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*
|
||||
* Cannot use dynamicActivate by itself to translate this specific page and all
|
||||
* children components because `not-found.tsx` page runs and overrides the i18n.
|
||||
*/
|
||||
export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
@ -44,6 +59,10 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||
|
||||
await dynamicActivate(i18n, documentLanguage);
|
||||
|
||||
const { data: auditLogs } = await findDocumentAuditLogs({
|
||||
documentId: documentId,
|
||||
userId: document.userId,
|
||||
@ -53,31 +72,35 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Version History</h1>
|
||||
<h1 className="my-8 text-2xl font-bold">{_(msg`Version History`)}</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
|
||||
<p>
|
||||
<span className="font-medium">Document ID</span>
|
||||
<span className="font-medium">{_(msg`Document ID`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.id}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Enclosed Document</span>
|
||||
<span className="font-medium">{_(msg`Enclosed Document`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">{document.title}</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Status</span>
|
||||
<span className="font-medium">{_(msg`Status`)}</span>
|
||||
|
||||
<span className="mt-1 block">{document.deletedAt ? 'DELETED' : document.status}</span>
|
||||
<span className="mt-1 block">
|
||||
{_(
|
||||
document.deletedAt ? msg`Deleted` : DOCUMENT_STATUS[document.status].description,
|
||||
).toUpperCase()}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Owner</span>
|
||||
<span className="font-medium">{_(msg`Owner`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.User.name} ({document.User.email})
|
||||
@ -85,7 +108,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Created At</span>
|
||||
<span className="font-medium">{_(msg`Created At`)}</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
{DateTime.fromJSDate(document.createdAt)
|
||||
@ -95,7 +118,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Last Updated</span>
|
||||
<span className="font-medium">{_(msg`Last Updated`)}</span>
|
||||
|
||||
<span className="mt-1 block">
|
||||
{DateTime.fromJSDate(document.updatedAt)
|
||||
@ -105,7 +128,7 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span className="font-medium">Time Zone</span>
|
||||
<span className="font-medium">{_(msg`Time Zone`)}</span>
|
||||
|
||||
<span className="mt-1 block break-words">
|
||||
{document.documentMeta?.timezone ?? 'N/A'}
|
||||
@ -113,13 +136,13 @@ export default async function AuditLog({ searchParams }: AuditLogProps) {
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-medium">Recipients</p>
|
||||
<p className="font-medium">{_(msg`Recipients`)}</p>
|
||||
|
||||
<ul className="mt-1 list-inside list-disc">
|
||||
{document.Recipient.map((recipient) => (
|
||||
<li key={recipient.id}>
|
||||
<span className="text-muted-foreground">
|
||||
[{RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName}]
|
||||
[{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}]
|
||||
</span>{' '}
|
||||
{recipient.name} ({recipient.email})
|
||||
</li>
|
||||
|
||||
@ -2,20 +2,24 @@ import React from 'react';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { APP_I18N_OPTIONS, ZSupportedLanguageCodeSchema } from '@documenso/lib/constants/i18n';
|
||||
import {
|
||||
RECIPIENT_ROLES_DESCRIPTION_ENG,
|
||||
RECIPIENT_ROLE_SIGNING_REASONS_ENG,
|
||||
RECIPIENT_ROLES_DESCRIPTION,
|
||||
RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
} from '@documenso/lib/constants/recipient-roles';
|
||||
import { getEntireDocument } from '@documenso/lib/server-only/admin/get-entire-document';
|
||||
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
|
||||
import { getDocumentCertificateAuditLogs } from '@documenso/lib/server-only/document/get-document-certificate-audit-logs';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import {
|
||||
@ -36,11 +40,21 @@ type SigningCertificateProps = {
|
||||
};
|
||||
|
||||
const FRIENDLY_SIGNING_REASONS = {
|
||||
['__OWNER__']: `I am the owner of this document`,
|
||||
...RECIPIENT_ROLE_SIGNING_REASONS_ENG,
|
||||
['__OWNER__']: msg`I am the owner of this document`,
|
||||
...RECIPIENT_ROLE_SIGNING_REASONS,
|
||||
};
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*
|
||||
* Cannot use dynamicActivate by itself to translate this specific page and all
|
||||
* children components because `not-found.tsx` page runs and overrides the i18n.
|
||||
*/
|
||||
export default async function SigningCertificate({ searchParams }: SigningCertificateProps) {
|
||||
const { i18n } = await setupI18nSSR();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { d } = searchParams;
|
||||
|
||||
if (typeof d !== 'string' || !d) {
|
||||
@ -63,6 +77,10 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
const documentLanguage = ZSupportedLanguageCodeSchema.parse(document.documentMeta?.language);
|
||||
|
||||
await dynamicActivate(i18n, documentLanguage);
|
||||
|
||||
const auditLogs = await getDocumentCertificateAuditLogs({
|
||||
id: documentId,
|
||||
});
|
||||
@ -98,17 +116,17 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
});
|
||||
|
||||
let authLevel = match(extractedAuthMethods.derivedRecipientActionAuth)
|
||||
.with('ACCOUNT', () => 'Account Re-Authentication')
|
||||
.with('TWO_FACTOR_AUTH', () => 'Two-Factor Re-Authentication')
|
||||
.with('PASSKEY', () => 'Passkey Re-Authentication')
|
||||
.with('EXPLICIT_NONE', () => 'Email')
|
||||
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
|
||||
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
|
||||
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
|
||||
.with('EXPLICIT_NONE', () => _(msg`Email`))
|
||||
.with(null, () => null)
|
||||
.exhaustive();
|
||||
|
||||
if (!authLevel) {
|
||||
authLevel = match(extractedAuthMethods.derivedRecipientAccessAuth)
|
||||
.with('ACCOUNT', () => 'Account Authentication')
|
||||
.with(null, () => 'Email')
|
||||
.with('ACCOUNT', () => _(msg`Account Authentication`))
|
||||
.with(null, () => _(msg`Email`))
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
@ -147,7 +165,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">Signing Certificate</h1>
|
||||
<h1 className="my-8 text-2xl font-bold">{_(msg`Signing Certificate`)}</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@ -155,9 +173,9 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Signer Events</TableHead>
|
||||
<TableHead>Signature</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
<TableHead>{_(msg`Signer Events`)}</TableHead>
|
||||
<TableHead>{_(msg`Signature`)}</TableHead>
|
||||
<TableHead>{_(msg`Details`)}</TableHead>
|
||||
{/* <TableHead>Security</TableHead> */}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@ -173,11 +191,11 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
|
||||
<div className="break-all">{recipient.email}</div>
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
{RECIPIENT_ROLES_DESCRIPTION_ENG[recipient.role].roleName}
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Authentication Level:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
|
||||
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||
</p>
|
||||
</TableCell>
|
||||
@ -199,21 +217,21 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">Signature ID:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
|
||||
<span className="block font-mono uppercase">
|
||||
{signature.secondaryId}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<span className="font-medium">IP Address:</span>{' '}
|
||||
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? 'Unknown'}
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||
<span className="font-medium">Device:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||
</span>
|
||||
@ -227,44 +245,46 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Sent:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.EMAIL_SENT[0]
|
||||
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||
: 'Unknown'}
|
||||
: _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Viewed:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_OPENED[0]
|
||||
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||
: 'Unknown'}
|
||||
: _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Signed:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
||||
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_COMPLETED[0].createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||
: 'Unknown'}
|
||||
: _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<span className="font-medium">Reason:</span>{' '}
|
||||
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{isOwner(recipient.email)
|
||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||
: FRIENDLY_SIGNING_REASONS[recipient.role]}
|
||||
{_(
|
||||
isOwner(recipient.email)
|
||||
? FRIENDLY_SIGNING_REASONS['__OWNER__']
|
||||
: FRIENDLY_SIGNING_REASONS[recipient.role],
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@ -280,7 +300,7 @@ export default async function SigningCertificate({ searchParams }: SigningCertif
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
|
||||
Signing certificate provided by:
|
||||
{_(msg`Signing certificate provided by`)}:
|
||||
</p>
|
||||
|
||||
<Logo className="max-h-6 print:max-h-4" />
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import type { TemplateEditPageViewProps } from '~/app/(dashboard)/templates/[id]/edit/template-edit-page-view';
|
||||
import { TemplateEditPageView } from '~/app/(dashboard)/templates/[id]/edit/template-edit-page-view';
|
||||
|
||||
export type TeamsTemplateEditPageProps = {
|
||||
params: TemplateEditPageViewProps['params'] & {
|
||||
teamUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TeamsTemplateEditPage({ params }: TeamsTemplateEditPageProps) {
|
||||
await setupI18nSSR();
|
||||
|
||||
const { teamUrl } = params;
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const team = await getTeamByUrl({ userId: user.id, teamUrl });
|
||||
|
||||
return <TemplateEditPageView params={params} team={team} />;
|
||||
}
|
||||
@ -12,7 +12,7 @@ import { UAParser } from 'ua-parser-js';
|
||||
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
@ -37,7 +37,7 @@ export const DocumentHistorySheet = ({
|
||||
onMenuOpenChange,
|
||||
children,
|
||||
}: DocumentHistorySheetProps) => {
|
||||
const { i18n } = useLingui();
|
||||
const { _, i18n } = useLingui();
|
||||
|
||||
const [isUserDetailsVisible, setIsUserDetailsVisible] = useState(false);
|
||||
|
||||
@ -152,7 +152,7 @@ export const DocumentHistorySheet = ({
|
||||
|
||||
<div>
|
||||
<p className="text-foreground text-xs font-bold">
|
||||
{formatDocumentAuditLogActionString(auditLog, userId)}
|
||||
{formatDocumentAuditLogAction(_, auditLog, userId).description}
|
||||
</p>
|
||||
<p className="text-foreground/50 text-xs">
|
||||
{DateTime.fromJSDate(auditLog.createdAt)
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { EyeOffIcon } from 'lucide-react';
|
||||
import { Clock, EyeOffIcon } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import {
|
||||
@ -18,8 +19,10 @@ import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { DocumentMeta } from '@documenso/prisma/client';
|
||||
import { FieldType, SigningStatus } from '@documenso/prisma/client';
|
||||
import { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
@ -27,9 +30,14 @@ import { PopoverHover } from '@documenso/ui/primitives/popover';
|
||||
export type DocumentReadOnlyFieldsProps = {
|
||||
fields: DocumentField[];
|
||||
documentMeta?: DocumentMeta;
|
||||
showFieldStatus?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnlyFieldsProps) => {
|
||||
export const DocumentReadOnlyFields = ({
|
||||
documentMeta,
|
||||
fields,
|
||||
showFieldStatus = true,
|
||||
}: DocumentReadOnlyFieldsProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [hiddenFieldIds, setHiddenFieldIds] = useState<Record<string, boolean>>({});
|
||||
@ -58,15 +66,37 @@ export const DocumentReadOnlyFields = ({ documentMeta, fields }: DocumentReadOnl
|
||||
</Avatar>
|
||||
}
|
||||
contentProps={{
|
||||
className: 'relative flex w-fit flex-col p-2.5 text-sm',
|
||||
className: 'relative flex w-fit flex-col p-4 text-sm',
|
||||
}}
|
||||
>
|
||||
<p className="font-semibold">
|
||||
{field.Recipient.signingStatus === SigningStatus.SIGNED ? 'Signed' : 'Pending'}{' '}
|
||||
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type]).toLowerCase()} field
|
||||
{showFieldStatus && (
|
||||
<Badge
|
||||
className="mx-auto mb-1 py-0.5"
|
||||
variant={
|
||||
field.Recipient.signingStatus === SigningStatus.SIGNED
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{field.Recipient.signingStatus === SigningStatus.SIGNED ? (
|
||||
<>
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<Trans>Signed</Trans>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<Trans>Pending</Trans>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<p className="text-center font-semibold">
|
||||
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="text-muted-foreground mt-1 text-center text-xs">
|
||||
{field.Recipient.name
|
||||
? `${field.Recipient.name} (${field.Recipient.email})`
|
||||
: field.Recipient.email}{' '}
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentRecipientLinkCopyDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
export const DocumentRecipientLinkCopyDialog = ({
|
||||
trigger,
|
||||
recipients,
|
||||
}: DocumentRecipientLinkCopyDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const actionSearchParam = searchParams?.get('action');
|
||||
|
||||
const onBulkCopy = async () => {
|
||||
const generatedString = recipients
|
||||
.filter((recipient) => recipient.role !== RecipientRole.CC)
|
||||
.map((recipient) => `${recipient.email}\n${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`)
|
||||
.join('\n\n');
|
||||
|
||||
await copy(generatedString).then(() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`All signing links have been copied to your clipboard.`),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (actionSearchParam === 'view-signing-links') {
|
||||
setOpen(true);
|
||||
updateSearchParams({ action: null });
|
||||
}
|
||||
}, [actionSearchParam, open, setOpen, updateSearchParams]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => setOpen(value)}>
|
||||
<DialogTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="pb-0.5">
|
||||
<Trans>Copy Signing Links</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You can copy and share these links to recipients so they can action the document.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="text-muted-foreground divide-y rounded-lg border">
|
||||
{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-3 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>
|
||||
}
|
||||
/>
|
||||
|
||||
{recipient.role !== RecipientRole.CC && (
|
||||
<CopyTextButton
|
||||
value={formatSigningLink(recipient.token)}
|
||||
onCopySuccess={() => {
|
||||
toast({
|
||||
title: _(msg`Copied to clipboard`),
|
||||
description: _(msg`The signing link has been copied to your clipboard.`),
|
||||
});
|
||||
}}
|
||||
badgeContentUncopied={
|
||||
<p className="ml-1 text-xs">
|
||||
<Trans>Copy</Trans>
|
||||
</p>
|
||||
}
|
||||
badgeContentCopied={
|
||||
<p className="ml-1 text-xs">
|
||||
<Trans>Copied</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="button" onClick={onBulkCopy}>
|
||||
<Trans>Bulk Copy</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
50
apps/web/src/components/forms/search-param-selector.tsx
Normal file
50
apps/web/src/components/forms/search-param-selector.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import { Select, SelectContent, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
|
||||
export type SearchParamSelector = {
|
||||
paramKey: string;
|
||||
isValueValid: (value: unknown) => boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SearchParamSelector = ({ children, paramKey, isValueValid }: SearchParamSelector) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const value = useMemo(() => {
|
||||
const p = searchParams?.get(paramKey) ?? 'all';
|
||||
|
||||
return isValueValid(p) ? p : 'all';
|
||||
}, [searchParams]);
|
||||
|
||||
const onValueChange = (newValue: string) => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(searchParams?.toString());
|
||||
|
||||
params.set(paramKey, newValue);
|
||||
|
||||
if (newValue === '' || newValue === 'all') {
|
||||
params.delete(paramKey);
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<Select defaultValue={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="text-muted-foreground max-w-[200px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent position="popper">{children}</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user