chore: merged feat/refresh

This commit is contained in:
pit
2023-09-26 16:27:11 +01:00
229 changed files with 8108 additions and 1272 deletions

BIN
apps/web/example/cert.p12 Normal file

Binary file not shown.

View File

@ -2,7 +2,7 @@
const path = require('path');
const { version } = require('./package.json');
const { parsed: env } = require('dotenv').config({
require('dotenv').config({
path: path.join(__dirname, '../../.env.local'),
});
@ -22,6 +22,7 @@ const config = {
],
env: {
APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
},
modularizeImports: {
'lucide-react': {

View File

@ -8,9 +8,10 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs",
"dev:test": "playwright test",
"e2e:test": "start-server-and-test \"npm run start\" http://localhost:3000 \"playwright test\""
"e2e:test": "start-server-and-test \"npm run start\" http://localhost:3000 \"playwright test\"",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/ee": "*",
@ -23,10 +24,10 @@
"@tanstack/react-query": "^4.29.5",
"formidable": "^2.1.1",
"framer-motion": "^10.12.8",
"lucide-react": "^0.214.0",
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.4.12",
"next": "13.4.19",
"next-auth": "4.22.3",
"next-plausible": "^3.10.1",
"next-themes": "^0.2.1",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Before

Width:  |  Height:  |  Size: 629 B

View File

@ -55,21 +55,18 @@ export const EditDocumentForm = ({
title: 'Add Signers',
description: 'Add the people who will sign the document.',
stepIndex: 1,
onSubmit: () => onAddSignersFormSubmit,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
onBackStep: () => setStep('signers'),
onSubmit: () => onAddFieldsFormSubmit,
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
stepIndex: 3,
onBackStep: () => setStep('fields'),
onSubmit: () => onAddSubjectFormSubmit,
},
};
@ -169,6 +166,7 @@ export const EditDocumentForm = ({
{step === 'signers' && (
<AddSignersFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
@ -179,6 +177,7 @@ export const EditDocumentForm = ({
{step === 'fields' && (
<AddFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}

View File

@ -7,7 +7,11 @@ import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type DataTableActionButtonProps = {
row: Document & {
@ -18,11 +22,16 @@ export type DataTableActionButtonProps = {
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
if (!session) {
return null;
}
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpc.shareLink.createOrGetShareLink.useMutation();
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
@ -32,6 +41,20 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onShareClick = async () => {
const { slug } = await createOrGetShareLink({
token: recipient?.token,
documentId: row.id,
});
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
};
return match({
isOwner,
isRecipient,
@ -57,8 +80,8 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
</Button>
))
.otherwise(() => (
<Button className="w-24" disabled>
<Share className="-ml-1 mr-2 h-4 w-4" />
<Button className="w-24" loading={isCreatingShareLink} onClick={onShareClick}>
{!isCreatingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
Share
</Button>
));

View File

@ -7,6 +7,7 @@ import {
Download,
Edit,
History,
Loader,
MoreHorizontal,
Pencil,
Share,
@ -18,7 +19,8 @@ import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/client';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import {
DropdownMenu,
DropdownMenuContent,
@ -26,6 +28,9 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type DataTableActionDropdownProps = {
row: Document & {
@ -36,11 +41,16 @@ export type DataTableActionDropdownProps = {
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
if (!session) {
return null;
}
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
trpcReact.shareLink.createOrGetShareLink.useMutation();
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
@ -50,15 +60,29 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onShareClick = async () => {
const { slug } = await createOrGetShareLink({
token: recipient?.token,
documentId: row.id,
});
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
};
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
if (!recipient) {
document = await trpc.document.getDocumentById.query({
document = await trpcClient.document.getDocumentById.query({
id: row.id,
});
} else {
document = await trpc.document.getDocumentByToken.query({
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
@ -88,7 +112,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="h-5 w-5 text-gray-500" />
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
@ -135,8 +159,12 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Resend
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Share className="mr-2 h-4 w-4" />
<DropdownMenuItem onClick={onShareClick}>
{isCreatingShareLink ? (
<Loader className="mr-2 h-4 w-4" />
) : (
<Share className="mr-2 h-4 w-4" />
)}
Share
</DropdownMenuItem>
</DropdownMenuContent>

View File

@ -92,8 +92,8 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
</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 className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)}
</div>

View File

@ -0,0 +1,50 @@
import { Bird, CheckCircle2 } from 'lucide-react';
import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type EmptyDocumentProps = { status: ExtendedDocumentStatus };
export const EmptyDocumentState = ({ status }: EmptyDocumentProps) => {
const {
title,
message,
icon: Icon,
} = match(status)
.with(ExtendedDocumentStatus.COMPLETED, () => ({
title: 'Nothing to do',
message:
'There are no completed documents yet. Documents that you have created or received that become completed will appear here later.',
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
title: 'No active drafts',
message:
'There are no active drafts at then current moment. You can upload a document to start drafting.',
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: "We're all empty",
message:
'You have not yet created or received any documents. To create a document please upload one.',
icon: Bird,
}))
.otherwise(() => ({
title: 'Nothing to do',
message:
'All documents are currently actioned. Any new documents are sent or recieved they will start to appear here.',
icon: CheckCircle2,
}));
return (
<div className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4">
<Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">{title}</h3>
<p className="mt-2 max-w-[60ch]">{message}</p>
</div>
</div>
);
};

View File

@ -12,6 +12,7 @@ import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/ty
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
export type DocumentsPageProps = {
@ -62,41 +63,44 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<UploadDocument />
<h1 className="mt-12 text-4xl font-semibold">Documents</h1>
<div className="mt-12 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<h1 className="text-4xl font-semibold">Documents</h1>
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
<Tabs defaultValue={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
<div className="flex flex-wrap gap-x-4 gap-y-6 overflow-hidden">
<Tabs defaultValue={status} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats[value], 99)}
{stats[value] > 99 && '+'}
</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
<DocumentsDataTable results={results} />
{results.count > 0 && <DocumentsDataTable results={results} />}
{results.count === 0 && <EmptyDocumentState status={status} />}
</div>
</div>
);

View File

@ -67,8 +67,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} />
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-12 w-12 animate-spin text-slate-500" />
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
</div>

View File

@ -4,12 +4,12 @@ import { redirect } from 'next/navigation';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { LocaleDate } from '~/components/formatter/locale-date';
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
export default async function BillingSettingsPage() {
const user = await getRequiredServerComponentSession();
@ -43,7 +43,7 @@ export default async function BillingSettingsPage() {
<div>
<h3 className="text-lg font-medium">Billing</h3>
<p className="mt-2 text-sm text-slate-500">
<p className="text-muted-foreground mt-2 text-sm">
Your subscription is{' '}
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
{subscription?.periodEnd && (
@ -67,7 +67,7 @@ export default async function BillingSettingsPage() {
)}
{!billingPortalUrl && (
<p className="max-w-[60ch] text-base text-slate-500">
<p className="text-muted-foreground max-w-[60ch] text-base">
You do not currently have a customer record, this should not happen. Please contact
support for assistance.
</p>

View File

@ -9,7 +9,7 @@ export default async function PasswordSettingsPage() {
<div>
<h3 className="text-lg font-medium">Password</h3>
<p className="mt-2 text-sm text-slate-500">Here you can update your password.</p>
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
<hr className="my-4" />

View File

@ -9,7 +9,7 @@ export default async function ProfileSettingsPage() {
<div>
<h3 className="text-lg font-medium">Profile</h3>
<p className="mt-2 text-sm text-slate-500">Here you can edit your personal details.</p>
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
<hr className="my-4" />

View File

@ -0,0 +1,153 @@
import { ImageResponse } from 'next/server';
import { P, match } from 'ts-pattern';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug';
import { Logo } from '~/components/branding/logo';
import { getAssetBuffer } from '~/helpers/get-asset-buffer';
const CARD_OFFSET_TOP = 152;
const CARD_OFFSET_LEFT = 350;
const CARD_WIDTH = 500;
const CARD_HEIGHT = 250;
const size = {
width: 1200,
height: 630,
};
type SharePageOpenGraphImageProps = {
params: { slug: string };
};
export default async function Image({ params: { slug } }: SharePageOpenGraphImageProps) {
const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([
getAssetBuffer('/fonts/inter-semibold.ttf'),
getAssetBuffer('/fonts/inter-regular.ttf'),
getAssetBuffer('/fonts/caveat-regular.ttf'),
getAssetBuffer('/static/og-share-frame.png'),
]);
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({ slug }).catch(() => null);
if (!recipientOrSender) {
return null;
}
const isRecipient = 'Signature' in recipientOrSender;
const signatureImage = match(recipientOrSender)
.with({ Signature: P.array(P._) }, (recipient) => {
return recipient.Signature?.[0]?.signatureImageAsBase64 || null;
})
.otherwise((sender) => {
return sender.signature || null;
});
const signatureName = match(recipientOrSender)
.with({ Signature: P.array(P._) }, (recipient) => {
return recipient.name || recipient.email;
})
.otherwise((sender) => {
return sender.name || sender.email;
});
return new ImageResponse(
(
<div tw="relative flex h-full w-full">
{/* @ts-expect-error Lack of typing from ImageResponse */}
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
<div tw="absolute top-20 flex w-full items-center justify-center">
{/* @ts-expect-error Lack of typing from ImageResponse */}
<Logo tw="h-8 w-60" />
</div>
{signatureImage ? (
<div
tw="absolute py-6 px-12 flex items-center justify-center text-center"
style={{
top: `${CARD_OFFSET_TOP}px`,
left: `${CARD_OFFSET_LEFT}px`,
width: `${CARD_WIDTH}px`,
height: `${CARD_HEIGHT}px`,
}}
>
<img src={signatureImage} alt="signature" tw="opacity-60 h-full max-w-[100%]" />
</div>
) : (
<p
tw="absolute py-6 px-12 -mt-2 flex items-center justify-center text-center text-slate-500"
style={{
fontFamily: 'Caveat',
fontSize: `${Math.max(
Math.min((CARD_WIDTH * 1.5) / signatureName.length, 80),
36,
)}px`,
top: `${CARD_OFFSET_TOP}px`,
left: `${CARD_OFFSET_LEFT}px`,
width: `${CARD_WIDTH}px`,
height: `${CARD_HEIGHT}px`,
}}
>
{signatureName}
</p>
)}
{/* <div
tw="absolute flex items-center justify-center text-slate-500"
style={{
top: `${CARD_OFFSET_TOP + CARD_HEIGHT - 45}px`,
left: `${CARD_OFFSET_LEFT}`,
width: `${CARD_WIDTH}px`,
fontSize: '30px',
}}
>
{signatureName}
</div> */}
<div
tw="absolute flex flex-col items-center justify-center pt-12 w-full"
style={{
top: `${CARD_OFFSET_TOP + CARD_HEIGHT}px`,
}}
>
<h2
tw="text-3xl text-slate-500"
style={{
fontFamily: 'Inter',
fontWeight: 600,
}}
>
{isRecipient
? 'I just signed with Documenso and you can too!'
: 'I just sent a document with Documenso and you can too!'}
</h2>
</div>
</div>
),
{
...size,
fonts: [
{
name: 'Caveat',
data: caveatRegular,
style: 'italic',
},
{
name: 'Inter',
data: interRegular,
style: 'normal',
weight: 400,
},
{
name: 'Inter',
data: interSemiBold,
style: 'normal',
weight: 600,
},
],
},
);
}

View File

@ -0,0 +1,11 @@
import { Metadata } from 'next';
import { Redirect } from './redirect';
export const metadata: Metadata = {
title: 'Documenso - Share',
};
export default function SharePage() {
return <Redirect />;
}

View File

@ -0,0 +1,11 @@
'use client';
import { useEffect } from 'react';
export const Redirect = () => {
useEffect(() => {
window.location.href = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001';
}, []);
return null;
};

View File

@ -1,78 +0,0 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import { Download } from 'lucide-react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DownloadButtonProps = HTMLAttributes<HTMLButtonElement> & {
disabled?: boolean;
fileName?: string;
documentData?: DocumentData;
};
export const DownloadButton = ({
className,
fileName,
documentData,
disabled,
...props
}: DownloadButtonProps) => {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const onDownloadClick = async () => {
try {
setIsLoading(true);
if (!documentData) {
return;
}
const bytes = await getFile(documentData);
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
} catch (err) {
console.error(err);
toast({
title: 'Error',
description: 'An error occurred while downloading your document.',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Button
type="button"
variant="outline"
className={className}
disabled={disabled || !documentData}
onClick={onDownloadClick}
loading={isLoading}
{...props}
>
<Download className="mr-2 h-5 w-5" />
Download
</Button>
);
};

View File

@ -1,17 +1,19 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8, Share } from 'lucide-react';
import { CheckCircle2, Clock8 } from 'lucide-react';
import { match } from 'ts-pattern';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { SigningCard } from '@documenso/ui/components/signing-card';
import { DownloadButton } from './download-button';
import { SigningCard } from './signing-card';
import signingCelebration from '~/assets/signing-celebration.png';
import { ShareButton } from './share-button';
export type CompletedSigningPageProps = {
params: {
@ -53,7 +55,7 @@ export default async function CompletedSigningPage({
return (
<div className="flex flex-col items-center pt-24">
{/* Card with recipient */}
<SigningCard name={recipientName} />
<SigningCard name={recipientName} signingCelebrationImage={signingCelebration} />
<div className="mt-6">
{match(document.status)
@ -88,13 +90,9 @@ export default async function CompletedSigningPage({
))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
{/* TODO: Hook this up */}
<Button variant="outline" className="flex-1">
<Share className="mr-2 h-5 w-5" />
Share
</Button>
<ShareButton documentId={document.id} token={recipient.token} />
<DownloadButton
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={documentData}
@ -103,7 +101,7 @@ export default async function CompletedSigningPage({
</div>
<p className="text-muted-foreground/60 mt-36 text-sm">
Want so send slick signing links like this one?{' '}
Want to send slick signing links like this one?{' '}
<Link href="https://documenso.com" className="text-documenso-700 hover:text-documenso-600">
Check out Documenso.
</Link>

View File

@ -0,0 +1,146 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import { Copy, Share, Twitter } from 'lucide-react';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type ShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
token: string;
documentId: number;
};
export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
const [isOpen, setIsOpen] = useState(false);
const {
mutateAsync: createOrGetShareLink,
data: shareLink,
isLoading,
} = trpc.shareLink.createOrGetShareLink.useMutation();
const onOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
void createOrGetShareLink({
token,
documentId,
});
}
setIsOpen(nextOpen);
};
const onCopyClick = async () => {
let { slug = '' } = shareLink || {};
if (!slug) {
const result = await createOrGetShareLink({
token,
documentId,
});
slug = result.slug;
}
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null);
toast({
title: 'Copied to clipboard',
description: 'The sharing link has been copied to your clipboard.',
});
setIsOpen(false);
};
const onTweetClick = async () => {
let { slug = '' } = shareLink || {};
if (!slug) {
const result = await createOrGetShareLink({
token,
documentId,
});
slug = result.slug;
}
window.open(
generateTwitterIntent(
`I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`,
`${window.location.origin}/share/${slug}`,
),
'_blank',
);
setIsOpen(false);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
disabled={!token || !documentId}
className="flex-1"
loading={isLoading}
>
{!isLoading && <Share className="mr-2 h-5 w-5" />}
Share
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Share</DialogTitle>
<DialogDescription className="mt-4">Share your signing experience!</DialogDescription>
</DialogHeader>
<div className="flex w-full flex-col">
<div className="rounded-md border p-4">
I just {token ? 'signed' : 'sent'} a document with{' '}
<span className="font-medium text-blue-400">@documenso</span>
. Check it out!
<span className="mt-2 block" />
<span className="font-medium text-blue-400">
{window.location.origin}/share/{shareLink?.slug || '...'}
</span>
</div>
<Button variant="outline" className="mt-4" onClick={onTweetClick}>
<Twitter className="mr-2 h-4 w-4" />
Tweet
</Button>
<div className="relative flex items-center justify-center gap-x-4 py-4 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or</span>
<div className="bg-border h-px flex-1" />
</div>
<Button variant="outline" onClick={onCopyClick}>
<Copy className="mr-2 h-4 w-4" />
Copy Link
</Button>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -1,67 +0,0 @@
'use client';
import Image from 'next/image';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import signingCelebration from '~/assets/signing-celebration.png';
export type SigningCardProps = {
name: string;
};
export const SigningCard = ({ name }: SigningCardProps) => {
return (
<div className="relative w-full max-w-xs md:max-w-sm">
<Card
className="group mx-auto flex aspect-[21/9] w-full items-center justify-center"
degrees={-145}
gradient
>
<CardContent
className="font-signature p-6 text-center"
style={{
container: 'main',
}}
>
<span
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
style={{
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
}}
>
{name}
</span>
</CardContent>
</Card>
<motion.div
className="absolute -inset-32 -z-10 flex items-center justify-center md:-inset-44 xl:-inset-60 2xl:-inset-80"
initial={{
opacity: 0,
scale: 0.8,
}}
animate={{
scale: 1,
opacity: 0.5,
}}
transition={{
delay: 0.5,
duration: 0.5,
}}
>
<Image
src={signingCelebration}
alt="background pattern"
className="w-full"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 67%)',
}}
/>
</motion.div>
</div>
);
};

View File

@ -77,7 +77,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}

View File

@ -81,7 +81,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}

View File

@ -1,12 +1,15 @@
'use client';
import { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { Document, Field, Recipient } from '@documenso/prisma/client';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -27,15 +30,22 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const {
handleSubmit,
formState: { isSubmitting },
} = useForm();
const isComplete = fields.every((f) => f.inserted);
const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
}, [fields]);
const onFormSubmit = async () => {
if (!isComplete) {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fields);
if (!isFieldsValid) {
return;
}
@ -54,7 +64,16 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}>
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
Click to insert field
</FieldToolTip>
)}
<fieldset
disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
>
<div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
@ -101,23 +120,18 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
onClick={() => router.back()}
>
Cancel
</Button>
<Button
className="w-full"
type="submit"
size="lg"
disabled={!isComplete || isSubmitting}
>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
Complete
</Button>
</div>
</div>
</div>
</div>
</fieldset>
</form>
);
};

View File

@ -100,7 +100,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}

View File

@ -38,7 +38,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
viewedDocument({ token }),
viewedDocument({ token }).catch(() => null),
]);
if (!document || !document.documentData || !recipient) {

View File

@ -115,7 +115,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center">
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
</div>
)}
@ -130,7 +130,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
<img
src={signature.signatureImageAsBase64}
alt={`Signature for ${recipient.name}`}
className="h-full w-full object-contain"
className="h-full w-full object-contain dark:invert"
/>
)}

View File

@ -3,10 +3,7 @@
import React from 'react';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { useFieldPageCoords } from '~/hooks/use-field-page-coords';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
export type SignatureFieldProps = {
field: FieldWithSignature;
@ -23,8 +20,6 @@ export const SigningFieldContainer = ({
onRemove,
children,
}: SignatureFieldProps) => {
const coords = useFieldPageCoords(field);
const onSignFieldClick = async () => {
if (field.inserted) {
return;
@ -42,40 +37,25 @@ export const SigningFieldContainer = ({
};
return (
<div
className="absolute"
style={{
top: `${coords.y}px`,
left: `${coords.x}px`,
height: `${coords.height}px`,
width: `${coords.width}px`,
}}
>
<Card
className="bg-background relative h-full w-full"
data-inserted={field.inserted ? 'true' : 'false'}
>
<CardContent
className={cn(
'text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2',
)}
<FieldRootContainer field={field}>
{!field.inserted && !loading && (
<button
type="submit"
className="absolute inset-0 z-10 h-full w-full"
onClick={onSignFieldClick}
/>
)}
{field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
{!field.inserted && !loading && (
<button type="submit" className="absolute inset-0 z-10" onClick={onSignFieldClick} />
)}
Remove
</button>
)}
{field.inserted && !loading && (
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
)}
{children}
</CardContent>
</Card>
</div>
{children}
</FieldRootContainer>
);
};

View File

@ -0,0 +1,20 @@
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export default function ForgotPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Email sent!</h1>
<p className="text-muted-foreground mb-4 mt-2 text-sm">
A password reset email has been sent, if you have an account you should see it in your inbox
shortly.
</p>
<Button asChild>
<Link href="/signin">Return to sign in</Link>
</Button>
</div>
);
}

View File

@ -0,0 +1,25 @@
import Link from 'next/link';
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
export default function ForgotPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Forgotten your password?</h1>
<p className="text-muted-foreground mt-2 text-sm">
No worries, it happens! Enter your email and we'll email you a special link to reset your
password.
</p>
<ForgotPasswordForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Remembered your password?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign In
</Link>
</p>
</div>
);
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import Image from 'next/image';
import backgroundPattern from '~/assets/background-pattern.png';
type UnauthenticatedLayoutProps = {
children: React.ReactNode;
};
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
return (
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div className="relative flex w-full max-w-md items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
/>
</div>
<div className="w-full">{children}</div>
</div>
</main>
);
}

View File

@ -0,0 +1,37 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { getResetTokenValidity } from '@documenso/lib/server-only/user/get-reset-token-validity';
import { ResetPasswordForm } from '~/components/forms/reset-password';
type ResetPasswordPageProps = {
params: {
token: string;
};
};
export default async function ResetPasswordPage({ params: { token } }: ResetPasswordPageProps) {
const isValid = await getResetTokenValidity({ token });
if (!isValid) {
redirect('/reset-password');
}
return (
<div className="w-full">
<h1 className="text-4xl font-semibold">Reset Password</h1>
<p className="text-muted-foreground mt-2 text-sm">Please choose your new password </p>
<ResetPasswordForm token={token} className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
</div>
);
}

View File

@ -0,0 +1,20 @@
import Link from 'next/link';
import { Button } from '@documenso/ui/primitives/button';
export default function ResetPasswordPage() {
return (
<div>
<h1 className="text-4xl font-semibold">Unable to reset password</h1>
<p className="text-muted-foreground mt-2 text-sm">
The token you have used to reset your password is either expired or it never existed. If you
have still forgotten your password, please request a new reset link.
</p>
<Button className="mt-4" asChild>
<Link href="/signin">Return to sign in</Link>
</Button>
</div>
);
}

View File

@ -1,43 +1,33 @@
import Image from 'next/image';
import Link from 'next/link';
import backgroundPattern from '~/assets/background-pattern.png';
import connections from '~/assets/card-sharing-figure.png';
import { SignInForm } from '~/components/forms/signin';
export default function SignInPage() {
return (
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div className="relative flex max-w-4xl items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:invert dark:sepia"
/>
</div>
<div>
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
<div className="max-w-md">
<h1 className="text-4xl font-semibold">Sign in to your account</h1>
<p className="text-muted-foreground/60 mt-2 text-sm">
Welcome back, we are lucky to have you.
</p>
<p className="text-muted-foreground/60 mt-2 text-sm">
Welcome back, we are lucky to have you.
</p>
<SignInForm className="mt-4" />
<SignInForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
<p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70">
Sign up
</Link>
</p>
</div>
<div className="hidden flex-1 lg:block">
<Image src={connections} alt="documenso connections" />
</div>
</div>
</main>
<p className="mt-2.5 text-center">
<Link
href="/forgot-password"
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
>
Forgotten your password?
</Link>
</p>
</div>
);
}

View File

@ -1,44 +1,25 @@
import Image from 'next/image';
import Link from 'next/link';
import backgroundPattern from '~/assets/background-pattern.png';
import connections from '~/assets/connections.png';
import { SignUpForm } from '~/components/forms/signup';
export default function SignUpPage() {
return (
<main className="bg-sand-100 relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div className="relative flex max-w-4xl items-center gap-x-24">
<div className="absolute -inset-96 -z-[1] flex items-center justify-center opacity-50">
<Image
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:invert dark:sepia"
/>
</div>
<div>
<h1 className="text-4xl font-semibold">Create a new account</h1>
<div className="max-w-md">
<h1 className="text-4xl font-semibold">Create a shiny, new Documenso Account </h1>
<p className="text-muted-foreground/60 mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and beautiful
signing is within your grasp.
</p>
<p className="text-muted-foreground/60 mt-2 text-sm">
Create your account and start using state-of-the-art document signing. Open and
beautiful signing is within your grasp.
</p>
<SignUpForm className="mt-4" />
<SignUpForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</div>
<div className="hidden flex-1 lg:block">
<Image src={connections} alt="documenso connections" />
</div>
</div>
</main>
<p className="text-muted-foreground mt-6 text-center text-sm">
Already have an account?{' '}
<Link href="/signin" className="text-primary duration-200 hover:opacity-70">
Sign in instead
</Link>
</p>
</div>
);
}

View File

@ -2,15 +2,15 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
import { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { getServerComponentAllFlags } from '~/helpers/get-server-component-feature-flag';
import { FeatureFlagProvider } from '~/providers/feature-flag';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
@ -76,6 +76,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>
</LocaleProvider>

View File

@ -0,0 +1,26 @@
import Link from 'next/link';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Button } from '@documenso/ui/primitives/button';
import NotFoundPartial from '~/components/partials/not-found';
export default async function NotFound() {
const session = await getServerComponentSession();
return (
<NotFoundPartial>
{session && (
<Button className="w-32" asChild>
<Link href="/documents">Documents</Link>
</Button>
)}
{!session && (
<Button className="w-32" asChild>
<Link href="/signin">Sign In</Link>
</Button>
)}
</NotFoundPartial>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

View File

@ -58,7 +58,7 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
))}
</div>
@ -75,7 +75,7 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
))}
</div>
@ -92,7 +92,7 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
))}
</div>
@ -109,7 +109,7 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-sm text-gray-500">{recipient.email}</span>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
))}
</div>

View File

@ -10,12 +10,14 @@ import {
User as LucideUser,
Monitor,
Moon,
Palette,
Sun,
UserCog,
} from 'lucide-react';
import { signOut } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { recipientInitials } from '@documenso/lib/utils/recipient-formatter';
import { User } from '@documenso/prisma/client';
@ -26,19 +28,23 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useFeatureFlags } from '~/providers/feature-flag';
export type ProfileDropdownProps = {
user: User;
};
export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
const { theme, setTheme } = useTheme();
const { getFlag } = useFeatureFlags();
const { theme, setTheme } = useTheme();
const isUserAdmin = isAdmin(user);
const isBillingEnabled = getFlag('app_billing');
@ -98,28 +104,30 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
<DropdownMenuSeparator />
{theme === 'light' ? null : (
<DropdownMenuItem onClick={() => setTheme('light')}>
<Sun className="mr-2 h-4 w-4" />
Light Mode
</DropdownMenuItem>
)}
{theme === 'dark' ? null : (
<DropdownMenuItem onClick={() => setTheme('dark')}>
<Moon className="mr-2 h-4 w-4" />
Dark Mode
</DropdownMenuItem>
)}
{theme === 'system' ? null : (
<DropdownMenuItem onClick={() => setTheme('system')}>
<Monitor className="mr-2 h-4 w-4" />
System Theme
</DropdownMenuItem>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="mr-2 h-4 w-4" />
Themes
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
<DropdownMenuRadioItem value="light">
<Sun className="mr-2 h-4 w-4" /> Light
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Moon className="mr-2 h-4 w-4" />
Dark
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="system">
<Monitor className="mr-2 h-4 w-4" />
System
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
<Github className="mr-2 h-4 w-4" />

View File

@ -44,7 +44,7 @@ export const PeriodSelector = () => {
return (
<Select defaultValue={period} onValueChange={onPeriodChange}>
<SelectTrigger className="max-w-[200px] text-slate-500">
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>

View File

@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useFeatureFlags } from '~/providers/feature-flag';
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {

View File

@ -7,11 +7,10 @@ import { usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { useFeatureFlags } from '~/providers/feature-flag';
export type MobileNavProps = HTMLAttributes<HTMLDivElement>;
export const MobileNav = ({ className, ...props }: MobileNavProps) => {

View File

@ -17,17 +17,17 @@ const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
PENDING: {
label: 'Pending',
icon: Clock,
color: 'text-blue-600',
color: 'text-blue-600 dark:text-blue-300',
},
COMPLETED: {
label: 'Completed',
icon: CheckCircle2,
color: 'text-green-500',
color: 'text-green-500 dark:text-green-300',
},
DRAFT: {
label: 'Draft',
icon: File,
color: 'text-yellow-500',
color: 'text-yellow-500 dark:text-yellow-200',
},
INBOX: {
label: 'Inbox',

View File

@ -1,6 +1,7 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
@ -8,12 +9,20 @@ export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
documentId: number;
};
export const completeDocument = async ({ documentId }: CompleteDocumentActionInput) => {
export const completeDocument = async ({ documentId, email }: CompleteDocumentActionInput) => {
'use server';
const { id: userId } = await getRequiredServerComponentSession();
await sendDocument({
if (email.message || email.subject) {
await upsertDocumentMeta({
documentId,
subject: email.subject,
message: email.message,
});
}
return await sendDocument({
userId,
documentId,
});

View File

@ -0,0 +1,80 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZForgotPasswordFormSchema = z.object({
email: z.string().email().min(1),
});
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
export type ForgotPasswordFormProps = {
className?: string;
};
export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
const router = useRouter();
const { toast } = useToast();
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<TForgotPasswordFormSchema>({
values: {
email: '',
},
resolver: zodResolver(ZForgotPasswordFormSchema),
});
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
await forgotPassword({ email }).catch(() => null);
toast({
title: 'Reset email sent',
description:
'A password reset email has been sent, if you have an account you should see it in your inbox shortly.',
duration: 5000,
});
reset();
router.push('/check-email');
};
return (
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
<FormErrorMessage className="mt-1.5" error={errors.email} />
</div>
<Button size="lg" loading={isSubmitting}>
Reset Password
</Button>
</form>
);
};

View File

@ -1,7 +1,9 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { Eye, EyeOff, Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@ -36,6 +38,9 @@ export type PasswordFormProps = {
export const PasswordForm = ({ className }: PasswordFormProps) => {
const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
handleSubmit,
@ -88,37 +93,69 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="password" className="text-slate-500">
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<Input
id="password"
type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2"
{...register('password')}
/>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('password')}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<div>
<Label htmlFor="repeated-password" className="text-slate-500">
<Label htmlFor="repeated-password" className="text-muted-foreground">
Repeat Password
</Label>
<Input
id="repeated-password"
type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2"
{...register('repeatedPassword')}
/>
<div className="relative">
<Input
id="repeated-password"
type={showConfirmPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('repeatedPassword')}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowConfirmPassword((show) => !show)}
>
{showConfirmPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
</div>

View File

@ -89,7 +89,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="full-name" className="text-slate-500">
<Label htmlFor="full-name" className="text-muted-foreground">
Full Name
</Label>
@ -99,7 +99,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
</div>
<div>
<Label htmlFor="email" className="text-slate-500">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
@ -107,7 +107,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
</div>
<div>
<Label htmlFor="signature" className="text-slate-500">
<Label htmlFor="signature" className="text-muted-foreground">
Signature
</Label>

View File

@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZResetPasswordFormSchema = z
.object({
password: z.string().min(6).max(72),
repeatedPassword: z.string().min(6).max(72),
})
.refine((data) => data.password === data.repeatedPassword, {
path: ['repeatedPassword'],
message: "Passwords don't match",
});
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
export type ResetPasswordFormProps = {
className?: string;
token: string;
};
export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps) => {
const router = useRouter();
const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
reset,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TResetPasswordFormSchema>({
values: {
password: '',
repeatedPassword: '',
},
resolver: zodResolver(ZResetPasswordFormSchema),
});
const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation();
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
try {
await resetPassword({
password,
token,
});
reset();
toast({
title: 'Password updated',
description: 'Your password has been updated successfully.',
duration: 5000,
});
router.push('/signin');
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to reset your password. Please try again later.',
});
}
}
};
return (
<form
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="password" className="text-muted-foreground">
<span>Password</span>
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('password')}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<div>
<Label htmlFor="repeatedPassword" className="text-muted-foreground">
<span>Repeat Password</span>
</Label>
<div className="relative">
<Input
id="repeated-password"
type={showConfirmPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('repeatedPassword')}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowConfirmPassword((show) => !show)}
>
{showConfirmPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
</div>
<Button size="lg" loading={isSubmitting}>
Reset Password
</Button>
</form>
);
};

View File

@ -1,11 +1,9 @@
'use client';
import { useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { Eye, EyeOff, Loader } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
@ -14,6 +12,7 @@ import { z } from 'zod';
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -39,9 +38,8 @@ export type SignInFormProps = {
};
export const SignInForm = ({ className }: SignInFormProps) => {
const searchParams = useSearchParams();
const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false);
const {
register,
@ -55,36 +53,29 @@ export const SignInForm = ({ className }: SignInFormProps) => {
resolver: zodResolver(ZSignInFormSchema),
});
const errorCode = searchParams?.get('error');
useEffect(() => {
let timeout: NodeJS.Timeout | null = null;
if (isErrorCode(errorCode)) {
timeout = setTimeout(() => {
toast({
variant: 'destructive',
description: ERROR_MESSAGES[errorCode] ?? 'An unknown error occurred',
});
}, 0);
}
return () => {
if (timeout) {
clearTimeout(timeout);
}
};
}, [errorCode, toast]);
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
try {
await signIn('credentials', {
const result = await signIn('credentials', {
email,
password,
callbackUrl: LOGIN_REDIRECT_PATH,
}).catch((err) => {
console.error(err);
redirect: false,
});
if (result?.error && isErrorCode(result.error)) {
toast({
variant: 'destructive',
description: ERROR_MESSAGES[result.error],
});
return;
}
if (!result?.url) {
throw new Error('An unknown error occurred');
}
window.location.href = result.url;
} catch (err) {
toast({
title: 'An unknown error occurred',
@ -113,33 +104,47 @@ export const SignInForm = ({ className }: SignInFormProps) => {
onSubmit={handleSubmit(onFormSubmit)}
>
<div>
<Label htmlFor="email" className="text-slate-500">
<Label htmlFor="email" className="text-muted-forground">
Email
</Label>
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
{errors.email && <span className="mt-1 text-xs text-red-500">{errors.email.message}</span>}
<FormErrorMessage className="mt-1.5" error={errors.email} />
</div>
<div>
<Label htmlFor="password" className="text-slate-500">
Password
<Label htmlFor="password" className="text-muted-forground">
<span>Password</span>
</Label>
<Input
id="password"
type="password"
minLength={6}
maxLength={72}
autoComplete="current-password"
className="bg-background mt-2"
{...register('password')}
/>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="current-password"
className="bg-background mt-2 pr-10"
{...register('password')}
/>
{errors.password && (
<span className="mt-1 text-xs text-red-500">{errors.password.message}</span>
)}
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<Button size="lg" disabled={isSubmitting} className="dark:bg-documenso dark:hover:opacity-90">

View File

@ -1,7 +1,9 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react';
import { Eye, EyeOff, Loader } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
@ -31,6 +33,7 @@ export type SignUpFormProps = {
export const SignUpForm = ({ className }: SignUpFormProps) => {
const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false);
const {
control,
@ -106,15 +109,31 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
Password
</Label>
<Input
id="password"
type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2"
{...register('password')}
/>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('password')}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
</div>
<div>

View File

@ -1,7 +0,0 @@
'use client';
import { motion } from 'framer-motion';
export * from 'framer-motion';
export const MotionDiv = motion.div;

View File

@ -0,0 +1,66 @@
'use client';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
export type NotFoundPartialProps = {
children?: React.ReactNode;
};
export default function NotFoundPartial({ children }: NotFoundPartialProps) {
const router = useRouter();
return (
<div className={cn('relative max-w-[100vw] overflow-hidden')}>
<div className="absolute -inset-24 -z-10">
<motion.div
className="flex h-full w-full items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 0.8, transition: { duration: 0.5, delay: 0.5 } }}
>
<Image
src={backgroundPattern}
alt="background pattern"
className="-mr-[50vw] -mt-[15vh] h-full scale-100 object-cover dark:contrast-[70%] dark:invert dark:sepia md:scale-100 lg:scale-[100%]"
priority
/>
</motion.div>
</div>
<div className="container mx-auto flex h-full min-h-screen items-center px-6 py-32">
<div>
<p className="text-muted-foreground font-semibold">404 Page not found</p>
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
<p className="text-muted-foreground mt-4 text-sm">
The page you are looking for was moved, removed, renamed or might never have existed.
</p>
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
<Button
variant="ghost"
className="w-32"
onClick={() => {
void router.back();
}}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
{children}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,14 @@
/**
* getAssetBuffer is used to retrieve array buffers for various assets
* that are hosted in the `public` folder.
*
* This exists due to a breakage with `import.meta.url` imports and open graph images,
* once we can identify a fix for this, we can remove this helper.
*
* @param path The path to the asset, relative to the `public` folder.
*/
export const getAssetBuffer = async (path: string) => {
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
};

View File

@ -1,79 +0,0 @@
import { z } from 'zod';
import { LOCAL_FEATURE_FLAGS, isFeatureFlagEnabled } from '@documenso/lib/constants/feature-flags';
import { TFeatureFlagValue, ZFeatureFlagValueSchema } from '~/providers/feature-flag.types';
/**
* Evaluate whether a flag is enabled for the current user.
*
* @param flag The flag to evaluate.
* @param options See `GetFlagOptions`.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getFlag = async (
flag: string,
options?: GetFlagOptions,
): Promise<TFeatureFlagValue> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false);
return response;
};
/**
* Get all feature flags for the current user if possible.
*
* @param options See `GetFlagOptions`.
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getAllFlags = async (
options?: GetFlagOptions,
): Promise<Record<string, TFeatureFlagValue>> => {
const requestHeaders = options?.requestHeaders ?? {};
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/feature-flag/all`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS);
};
interface GetFlagOptions {
/**
* The headers to attach to the request to evaluate flags.
*
* The authenticated user will be derived from the headers if possible.
*/
requestHeaders: Record<string, string>;
}

View File

@ -1,16 +0,0 @@
import { PostHog } from 'posthog-node';
import { extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
export default function PostHogServerClient() {
const postHogConfig = extractPostHogConfig();
if (!postHogConfig) {
return null;
}
return new PostHog(postHogConfig.key, {
host: postHogConfig.host,
fetch: async (...args) => fetch(...args),
});
}

View File

@ -1,26 +0,0 @@
import { headers } from 'next/headers';
import { getAllFlags, getFlag } from './get-feature-flag';
/**
* Evaluate whether a flag is enabled for the current user in a server component.
*
* @param flag The flag to evaluate.
* @returns Whether the flag is enabled, or the variant value of the flag.
*/
export const getServerComponentFlag = async (flag: string) => {
return await getFlag(flag, {
requestHeaders: Object.fromEntries(headers().entries()),
});
};
/**
* Get all feature flags for the current user from a server component.
*
* @returns A record of flags and their values for the user derived from the headers.
*/
export const getServerComponentAllFlags = async () => {
return await getAllFlags({
requestHeaders: Object.fromEntries(headers().entries()),
});
};

View File

@ -1,18 +0,0 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay: number) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@ -1,78 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Field } from '@documenso/prisma/client';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const { top, left, height, width } = getBoundingClientRect($page);
// X and Y are percentages of the page's height and width
const fieldX = (Number(field.positionX) / 100) * width + left;
const fieldY = (Number(field.positionY) / 100) * height + top;
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
setCoords({
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
useEffect(() => {
const onResize = () => {
calculateCoords();
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
}
const observer = new ResizeObserver(() => {
calculateCoords();
});
observer.observe($page);
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
return coords;
};

View File

@ -1,44 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
import handlerFeatureFlagAll from '@documenso/lib/server-only/feature-flags/all';
export const config = {
runtime: 'edge',
};
/**
* Get all the evaluated feature flags based on the current user if possible.
*/
export default async function handler(req: Request) {
const requestHeaders = Object.fromEntries(req.headers.entries());
const nextReq = new NextRequest(req, {
headers: requestHeaders,
});
const token = await getToken({ req: nextReq });
const postHog = PostHogServerClient();
// Return the local feature flags if PostHog is not enabled, true by default.
// The front end should not call this API if PostHog is not enabled to reduce network requests.
if (!postHog) {
return NextResponse.json(LOCAL_FEATURE_FLAGS);
}
const distinctId = extractDistinctUserId(token, nextReq);
const featureFlags = await postHog.getAllFlags(distinctId, mapJwtToFlagProperties(token));
const res = NextResponse.json(featureFlags);
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
return res;
}
export default handlerFeatureFlagAll;

View File

@ -1,122 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { JWT, getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import { nanoid } from '@documenso/lib/universal/id';
import PostHogServerClient from '~/helpers/get-post-hog-server-client';
import handlerFeatureFlagGet from '@documenso/lib/server-only/feature-flags/get';
export const config = {
runtime: 'edge',
};
/**
* Evaluate a single feature flag based on the current user if possible.
*
* @param req The request with a query parameter `flag`. Example request URL: /api/feature-flag/get?flag=flag-name
* @returns A Response with the feature flag value.
*/
export default async function handler(req: Request) {
const { searchParams } = new URL(req.url ?? '');
const flag = searchParams.get('flag');
const requestHeaders = Object.fromEntries(req.headers.entries());
const nextReq = new NextRequest(req, {
headers: requestHeaders,
});
const token = await getToken({ req: nextReq });
if (!flag) {
return NextResponse.json(
{
error: 'Missing flag query parameter.',
},
{
status: 400,
headers: {
'content-type': 'application/json',
},
},
);
}
const postHog = PostHogServerClient();
// Return the local feature flags if PostHog is not enabled, true by default.
// The front end should not call this API if PostHog is disabled to reduce network requests.
if (!postHog) {
return NextResponse.json(LOCAL_FEATURE_FLAGS[flag] ?? true);
}
const distinctId = extractDistinctUserId(token, nextReq);
const featureFlag = await postHog.getFeatureFlag(flag, distinctId, mapJwtToFlagProperties(token));
const res = NextResponse.json(featureFlag);
res.headers.set('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=300');
return res;
}
/**
* Map a JWT to properties which are consumed by PostHog to evaluate feature flags.
*
* @param jwt The JWT of the current user.
* @returns A map of properties which are consumed by PostHog.
*/
export const mapJwtToFlagProperties = (
jwt?: JWT | null,
): {
groups?: Record<string, string>;
personProperties?: Record<string, string>;
groupProperties?: Record<string, Record<string, string>>;
} => {
return {
personProperties: {
email: jwt?.email ?? '',
},
groupProperties: {
// Add properties to group users into different groups, such as billing plan.
},
};
};
/**
* Extract a distinct ID from a JWT and request.
*
* Will fallback to a random ID if no ID could be extracted from either the JWT or request.
*
* @param jwt The JWT of the current user.
* @param request Request potentially containing a PostHog `distinct_id` cookie.
* @returns A distinct user ID.
*/
export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): string => {
const config = extractPostHogConfig();
const email = jwt?.email;
const userId = jwt?.id.toString();
let fallbackDistinctId = nanoid();
if (config) {
try {
const postHogCookie = JSON.parse(
request.cookies.get(`ph_${config.key}_posthog`)?.value ?? '',
);
const postHogDistinctId = postHogCookie['distinct_id'];
if (typeof postHogDistinctId === 'string') {
fallbackDistinctId = postHogDistinctId;
}
} catch {
// Do nothing.
}
}
return email ?? userId ?? fallbackDistinctId;
};
export default handlerFeatureFlagGet;

View File

@ -1,96 +0,0 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import {
FEATURE_FLAG_POLL_INTERVAL,
LOCAL_FEATURE_FLAGS,
isFeatureFlagEnabled,
} from '@documenso/lib/constants/feature-flags';
import { getAllFlags } from '~/helpers/get-feature-flag';
import { TFeatureFlagValue } from './feature-flag.types';
export type FeatureFlagContextValue = {
getFlag: (_key: string) => TFeatureFlagValue;
};
export const FeatureFlagContext = createContext<FeatureFlagContextValue | null>(null);
export const useFeatureFlags = () => {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
}
return context;
};
export function FeatureFlagProvider({
children,
initialFlags,
}: {
children: React.ReactNode;
initialFlags: Record<string, TFeatureFlagValue>;
}) {
const [flags, setFlags] = useState(initialFlags);
const getFlag = useCallback(
(flag: string) => {
if (!isFeatureFlagEnabled()) {
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
return flags[flag] ?? false;
},
[flags],
);
/**
* Refresh the flags every `FEATURE_FLAG_POLL_INTERVAL` amount of time if the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const interval = setInterval(() => {
if (document.hasFocus()) {
void getAllFlags().then((newFlags) => setFlags(newFlags));
}
}, FEATURE_FLAG_POLL_INTERVAL);
return () => {
clearInterval(interval);
};
}, []);
/**
* Refresh the flags when the window is focused.
*/
useEffect(() => {
if (!isFeatureFlagEnabled()) {
return;
}
const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return (
<FeatureFlagContext.Provider
value={{
getFlag,
}}
>
{children}
</FeatureFlagContext.Provider>
);
}

View File

@ -1,10 +0,0 @@
import { z } from 'zod';
export const ZFeatureFlagValueSchema = z.union([
z.boolean(),
z.string(),
z.number(),
z.undefined(),
]);
export type TFeatureFlagValue = z.infer<typeof ZFeatureFlagValueSchema>;