Compare commits

...

57 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
986030cc38 chore: failed attempt at adding custom field labels
I have spent all day on it and for some reason, I can't figure it out
2024-02-18 16:01:03 +00:00
David Nguyen
2815b1a809 feat: add enterprise billing (#939)
## Description

Add support for enterprise billing plans.

Enterprise billing plans by default get access to everything early
adopters do:
- Unlimited teams
- Unlimited documents

They will also get additional features in the future.

## Notes

Pending webhook updates to support enterprise onboarding.
Rolled back env changes `NEXT_PUBLIC_PROJECT` since it doesn't seem to
work.
2024-02-17 12:42:00 +11:00
Lucas Smith
960914aeb5 fix: undo operation on signature pad (#868)
fixes: #864
2024-02-16 22:57:14 +11:00
Lucas Smith
d83769b410 chore: use unsafe effect 2024-02-16 11:56:02 +00:00
Lucas Smith
a0cf2a2c75 fix: improved document-dropzone ui for small vertical screens (#857)
improved document-dropzone ui for small vertical screens (screens less
than 800px vertically)
Although it can still become congested on really small vertical screens,
but possibility is really low.

fixes: #840
2024-02-16 22:03:24 +11:00
Lucas Smith
a30b73ce86 fix: update css 2024-02-16 11:02:04 +00:00
Lucas Smith
46d163d9d6 fix: highlighting issue in recipient selection (#937)
fixes: #920 

<img width="391" alt="image"
src="https://github.com/documenso/documenso/assets/75713174/08b2f5ab-4a6f-423a-a2fa-8f7b04789bb8">
2024-02-16 21:50:53 +11:00
Lucas Smith
681a89cfe1 chore: minor lint fixes (#934) 2024-02-16 21:48:45 +11:00
Lucas Smith
e5f4edc120 chore: create security.txt (#878)
Adding a security.txt file enables security researchers to quickly and
easily see where they can submit security issues and know that they are
being taken serious. From the proposal website:

> "When security risks in web services are discovered by independent
security researchers who understand the severity of the risk, they often
lack the channels to disclose them properly. As a result, security
issues may be left unreported. security.txt defines a standard to help
organizations define the process for security researchers to disclose
security vulnerabilities securely.”

See also https://securitytxt.org
2024-02-16 12:34:41 +11:00
Sumit Bisht
25291b64eb fix: highlighting issue in recipient selection 2024-02-15 22:25:23 +05:30
Lucas Smith
fe2093fe7c feat: add next-runtime-env (#869)
This PR adds the package
[next-runtime-env](https://github.com/expatfile/next-runtime-env/) to
populate the public environment variables at runtime.
2024-02-15 22:10:21 +11:00
Ephraim Atta-Duncan
49cddfab38 chore: lint with oxc 2024-02-15 06:11:50 +00:00
Timur Ercan
3e12a05ab8 chore: more grammar 2024-02-14 17:19:48 +01:00
Timur Ercan
a76504c0a4 Merge branch 'main' into chore-security-text 2024-02-14 17:16:44 +01:00
Timur Ercan
abab0c0a22 chore: grammer and format 2024-02-14 17:14:43 +01:00
Lucas Smith
f72b669f67 feat: restrict app access for unverified users (#835) 2024-02-13 20:22:43 +11:00
Lucas Smith
536cafde31 Merge branch 'main' into feat/disable-access-unverified-users 2024-02-13 20:19:16 +11:00
Lucas Smith
d052f02013 chore: refactor code 2024-02-13 06:01:25 +00:00
Catalin Pit
4878cf388f chore: add the missing signIn function 2024-02-13 07:53:36 +02:00
Catalin Pit
149f416be7 chore: refactor code 2024-02-13 07:50:22 +02:00
Catalin Pit
c432261dd8 chore: disable button while form is submitting 2024-02-12 09:49:59 +02:00
Catalin Pit
1852aa4b05 chore: add info 2024-02-12 09:49:59 +02:00
David Nguyen
a868ecf2d2 fix: restrict team verification tokens (#927)
## Description

Currently we're not restricting team transfer and email verification
tokens from flowing into the frontend.

This changes restricts it to only return the required information
instead of the whole data object.
2024-02-12 18:23:07 +11:00
Lucas Smith
51608ed390 fix: lint issue 2024-02-12 02:02:43 +00:00
Lucas Smith
8ebef831ac Merge branch 'main' into feat/add-runtime-env 2024-02-12 12:30:35 +11:00
Lucas Smith
20e2976731 fix: build issues 2024-02-12 01:29:22 +00:00
Lucas Smith
748bf6de6b fix: add dropped constants from merge 2024-02-08 22:12:04 +11:00
Lucas Smith
d13cf743bf Merge branch 'main' into feat/add-runtime-env 2024-02-08 22:06:59 +11:00
David Nguyen
cad48236a0 Merge branch 'main' into feat/disable-access-unverified-users 2024-02-07 16:30:22 +11:00
apoorv taneja
c970abc871 added onchange handler 2024-02-02 20:46:54 +05:30
apoorv taneja
d5b3df1648 fixed variable declaration 2024-02-02 19:32:39 +05:30
apoorv taneja
142c1c003e changed useEffect variables 2024-02-02 18:16:54 +05:30
apoorv taneja
a06c628653 Merge branch 'main' of https://github.com/plxity/documenso into fix/undo-button-in-canvas 2024-02-02 17:56:58 +05:30
apoorv taneja
7ca3697303 Merge branch 'main' of https://github.com/plxity/documenso into fix/undo-button-in-canvas 2024-02-02 14:49:06 +05:30
Lucas Smith
8ac2209493 Merge branch 'main' into chore-security-text 2024-02-02 16:16:25 +11:00
Lucas Smith
9c4ec34a3c fix: add precommit step for .well-known 2024-02-02 04:00:28 +00:00
Timur Ercan
1f142e334a Merge branch 'main' into chore-security-text 2024-01-31 20:31:34 +01:00
Mythie
08f82b23dc fix: update env entries to evaluate at runtime 2024-01-31 22:32:42 +11:00
Timur Ercan
747a7b0aea chore: security contacts and descr 2024-01-30 16:15:32 +01:00
Catalin Pit
6053a4a40a chore: refactor 2024-01-30 12:56:32 +02:00
Catalin Pit
cc090adce0 chore: refactor 2024-01-30 12:54:48 +02:00
Timur Ercan
375df71f5c Merge branch 'main' into chore-security-text 2024-01-29 16:43:57 +01:00
Catalin Pit
c0bb5205e1 chore: merged main 2024-01-29 10:14:56 +02:00
Catalin Pit
1676f5bf6c chore: removed unused code 2024-01-29 09:43:38 +02:00
Catalin Pit
f514d55d27 chore: removed unused schema 2024-01-29 09:41:02 +02:00
Tangerine Kugelmann
927a656c57 Create security.txt
See also https://securitytxt.org
2024-01-28 01:00:07 +01:00
Catalin Pit
751fb5275c Merge branch 'main' into feat/add-runtime-env 2024-01-26 14:04:54 +02:00
Catalin Pit
b2cca9afb6 chore: refactor 2024-01-26 13:27:36 +02:00
Catalin Pit
e2fa01509d chore: avoid returning unnecessary info 2024-01-25 17:33:35 +02:00
Catalin Pit
311c8da8fc chore: encrypt and decrypt email addr 2024-01-25 17:24:37 +02:00
Catalin Pit
49ecfc1a2c chore: refactor 2024-01-25 15:42:40 +02:00
Catalin Pit
ffee2b2c9a chore: merged main 2024-01-25 13:43:11 +02:00
Catalin Pit
2f18518961 chore: merged main 2024-01-25 10:53:05 +02:00
Catalin Pit
d451a7acce feat: add next-runtime-env 2024-01-25 10:48:20 +02:00
apoorv taneja
d8aecc4092 fixed undo operation on signature pad 2024-01-25 13:21:55 +05:30
Sumit Bisht
e5c2263e92 fix: imporoved document-dropzone ui for small vertical screens 2024-01-23 18:37:02 +05:30
Catalin Pit
4aefb80989 feat: restrict app access for unverified users 2024-01-16 14:25:05 +02:00
105 changed files with 868 additions and 56915 deletions

View File

@@ -1,4 +1,16 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
SCRIPT_DIR="$(readlink -f "$(dirname "$0")")"
MONOREPO_ROOT="$(readlink -f "$SCRIPT_DIR/../")"
echo "Copying pdf.js"
npm run copy:pdfjs --workspace apps/**
echo "Copying .well-known/ contents"
node "$MONOREPO_ROOT/scripts/copy-wellknown.cjs"
git add "$MONOREPO_ROOT/apps/web/public/"
git add "$MONOREPO_ROOT/apps/marketing/public/"
npx lint-staged

7
.well-known/security.txt Normal file
View File

@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt

View File

@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-01-25
Tags:
tags:
- Vision
- Mission
- Open Source

View File

@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-01-10
Tags:
tags:
- GitHub
- Backlog
- Roadmap

View File

@@ -5,7 +5,7 @@ authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder'
date: 2024-02-06
Tags:
tags:
- Founders
- Mission
- Open Source

View File

@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
const document = allDocuments.find((post) => post._raw.flattenedPath === params.content);
if (!document) {
notFound();
return { title: 'Not Found' };
}
return { title: document.title };

View File

@@ -7,6 +7,8 @@ import { ChevronLeft } from 'lucide-react';
import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks';
export const dynamic = 'force-dynamic';
export const generateStaticParams = () =>
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
@@ -14,7 +16,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
const blogPost = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);
if (!blogPost) {
notFound();
return {
title: 'Not Found',
};
}
return {

View File

@@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { ArrowRight } from 'lucide-react';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
@@ -12,6 +13,8 @@ import { Button } from '@documenso/ui/primitives/button';
import { PasswordReveal } from '~/components/(marketing)/password-reveal';
export const dynamic = 'force-dynamic';
const fontCaveat = Caveat({
weight: ['500'],
subsets: ['latin'],
@@ -175,11 +178,7 @@ export default async function ClaimedPlanPage({ searchParams = {} }: ClaimedPlan
This is a temporary password. Please change it as soon as possible.
</p>
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`}
target="_blank"
className="mt-4 block"
>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signin`} target="_blank" className="mt-4 block">
<Button size="lg" className="text-base">
Let's get started!
<ArrowRight className="ml-2 h-5 w-5" />

View File

@@ -147,7 +147,12 @@ export default async function OpenPage() {
<p className="text-muted-foreground mt-4 max-w-[60ch] text-center text-lg leading-normal">
All our metrics, finances, and learnings are public. We believe in transparency and want
to share our journey with you. You can read more about why here:{' '}
<a className="font-bold" href="https://documenso.com/blog/pre-seed" target="_blank">
<a
className="font-bold"
href="https://documenso.com/blog/pre-seed"
target="_blank"
rel="noreferrer"
>
Announcing Open Metrics
</a>
</p>

View File

@@ -15,6 +15,8 @@ export const metadata: Metadata = {
title: 'Pricing',
};
export const dynamic = 'force-dynamic';
export type PricingPageProps = {
searchParams?: {
planId?: string;
@@ -53,7 +55,7 @@ export default function PricingPage() {
<div className="mt-4 flex justify-center">
<Button variant="outline" size="lg" className="rounded-full hover:cursor-pointer" asChild>
<Link href="https://github.com/documenso/documenso" target="_blank">
<Link href="https://github.com/documenso/documenso" target="_blank" rel="noreferrer">
Get Started
</Link>
</Button>
@@ -166,6 +168,7 @@ export default function PricingPage() {
<Link
className="text-documenso-700 font-bold"
target="_blank"
rel="noreferrer"
href="mailto:support@documenso.com"
>
support@documenso.com
@@ -175,6 +178,7 @@ export default function PricingPage() {
className="text-documenso-700 font-bold"
href="https://documen.so/discord"
target="_blank"
rel="noreferrer"
>
in our Discord-Support-Channel
</a>{' '}

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client';
@@ -190,7 +191,7 @@ export const SinglePlayerClient = () => {
<p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '}
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors"
>

View File

@@ -7,6 +7,7 @@ export const metadata: Metadata = {
};
export const revalidate = 0;
export const dynamic = 'force-dynamic';
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during

View File

@@ -3,6 +3,7 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -17,32 +18,35 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
export function generateMetadata() {
return {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3000'),
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: ['/opengraph-image.jpg'],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: ['/opengraph-image.jpg'],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getAllAnonymousFlags();

View File

@@ -8,6 +8,7 @@ import Link from 'next/link';
import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -82,11 +83,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="rounded-full text-base" asChild>
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
target="_blank"
className="mt-6"
>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`} target="_blank" className="mt-6">
Signup Now
</Link>
</Button>
@@ -117,13 +114,13 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
</p>
<Button className="mt-6 rounded-full text-base" asChild>
<Link href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}>Signup Now</Link>
<Link href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}>Signup Now</Link>
</Button>
<div className="mt-8 flex w-full flex-col divide-y">
<p className="text-foreground py-4 font-medium">
{' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank">
<a href="https://documenso.com/blog/early-adopters" target="_blank" rel="noreferrer">
The Early Adopter Deal:
</a>
</p>
@@ -133,7 +130,11 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
<p className="text-foreground py-4">
<strong>
{' '}
<a href="https://documenso.com/blog/early-adopters" target="_blank">
<a
href="https://documenso.com/blog/early-adopters"
target="_blank"
rel="noreferrer"
>
Includes all upcoming features
</a>
</strong>

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { Signature } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
@@ -85,7 +86,7 @@ export const SinglePlayerModeSuccess = ({
<p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '}
<Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`}
href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap"
>

View File

@@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { env } from 'next-runtime-env';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -144,7 +145,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
setTimeout(resolve, 1000);
});
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;
const planId = env('NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID');
if (!planId) {
throw new Error('No plan ID found.');
}
const claimPlanInput = signatureDataUrl
? {

View File

@@ -1,13 +1,15 @@
import { NextApiRequest, NextApiResponse } from 'next';
import type { NextApiRequest, NextApiResponse } from 'next';
import { randomUUID } from 'crypto';
import { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
import type { TEarlyAdopterCheckoutMetadataSchema } from '@documenso/ee/server-only/stripe/webhook/early-adopter-checkout-metadata';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { TClaimPlanResponseSchema, ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
import type { TClaimPlanResponseSchema } from '~/api/claim-plan/types';
import { ZClaimPlanRequestSchema } from '~/api/claim-plan/types';
export default async function handler(
req: NextApiRequest,
@@ -40,7 +42,7 @@ export default async function handler(
if (user) {
return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/signin`,
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/signin`,
});
}
@@ -77,8 +79,8 @@ export default async function handler(
mode: 'subscription',
metadata,
allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`,
success_url: `${NEXT_PUBLIC_MARKETING_URL()}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${NEXT_PUBLIC_MARKETING_URL()}`,
});
if (!checkout.url) {

View File

@@ -0,0 +1,7 @@
# General Issues
Contact: https://github.com/documenso/documenso/issues/new?assignees=&labels=bug&projects=&template=bug-report.yml
# Report critical issues privately to let us take appropriate action before publishing.
Contact: mailto:security@documenso.com
Preferred-Languages: en
Canonical: https://documenso.com/.well-known/security.txt

View File

@@ -108,88 +108,86 @@ export const ResendDocumentActionItem = ({
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -117,7 +117,7 @@ export const UploadDocument = ({ className, team }: UploadDocumentProps) => {
return (
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
className="h-[min(400px,50vh)]"
disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop}

View File

@@ -2,6 +2,7 @@
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
export const createBillingPortal = async () => {
@@ -11,6 +12,6 @@ export const createBillingPortal = async () => {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
};

View File

@@ -3,6 +3,7 @@
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
@@ -27,13 +28,13 @@ export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
if (foundSubscription) {
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
});
};

View File

@@ -5,7 +5,7 @@ import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPricesByPlan } from '@documenso/ee/server-only/stripe/get-prices-by-plan';
import { getPrimaryAccountPlanPrices } from '@documenso/ee/server-only/stripe/get-primary-account-plan-prices';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
@@ -37,23 +37,23 @@ export default async function BillingSettingsPage() {
user = await getStripeCustomerByUser(user).then((result) => result.user);
}
const [subscriptions, prices, communityPlanPrices] = await Promise.all([
const [subscriptions, prices, primaryAccountPlanPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }),
getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY),
getPrimaryAccountPlanPrices(),
]);
const communityPlanPriceIds = communityPlanPrices.map(({ id }) => id);
const primaryAccountPlanPriceIds = primaryAccountPlanPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null;
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) =>
communityPlanPriceIds.includes(priceId),
const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
primaryAccountPlanPriceIds.includes(priceId),
);
const subscription =
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
communityPlanUserSubscriptions[0];
primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
primaryAccountPlanSubscriptions[0];
if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(

View File

@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { ShareHandlerAPIResponse } from '~/pages/api/share';
export const runtime = 'edge';
@@ -37,7 +39,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
),
]);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
new URL(`/api/share?slug=${slug}`, baseUrl),

View File

@@ -1,8 +1,8 @@
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { NEXT_PUBLIC_MARKETING_URL } from '@documenso/lib/constants/app';
type SharePageProps = {
params: { slug: string };
@@ -16,12 +16,12 @@ export function generateMetadata({ params: { slug } }: SharePageProps) {
title: 'Documenso - Join the open source signing revolution',
description: 'I just signed with Documenso!',
type: 'website',
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
images: [`/share/${slug}/opengraph`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${APP_BASE_URL}/share/${slug}/opengraph`],
images: [`/share/${slug}/opengraph`],
description: 'I just signed with Documenso!',
},
} satisfies Metadata;
@@ -35,5 +35,5 @@ export default function SharePage() {
return null;
}
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001');
redirect(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001');
}

View File

@@ -18,7 +18,7 @@ export type TeamTransferStatusProps = {
className?: string;
currentUserTeamRole: TeamMemberRole;
teamId: number;
transferVerification: TeamTransferVerification | null;
transferVerification: Pick<TeamTransferVerification, 'email' | 'expiresAt' | 'name'> | null;
};
export const TeamTransferStatus = ({

View File

@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
@@ -18,6 +20,8 @@ type SignInPageProps = {
};
export default function SignInPage({ searchParams }: SignInPageProps) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
const rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
@@ -39,7 +43,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED}
/>
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
{NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
<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">

View File

@@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
@@ -18,7 +20,9 @@ type SignUpPageProps = {
};
export default function SignUpPage({ searchParams }: SignUpPageProps) {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin');
}

View File

@@ -0,0 +1,27 @@
import { Mails } from 'lucide-react';
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
export default function UnverifiedAccount() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<Mails className="text-primary h-10 w-10" strokeWidth={2} />
</div>
<div className="">
<h2 className="text-2xl font-bold md:text-4xl">Confirm email</h2>
<p className="text-muted-foreground mt-4">
To gain access to your account, please confirm your email address by clicking on the
confirmation link from your inbox.
</p>
<p className="text-muted-foreground mt-4">
If you don't find the confirmation link in your inbox, you can request a new one below.
</p>
<SendConfirmationEmailForm />
</div>
</div>
);
}

View File

@@ -2,8 +2,11 @@ import { Suspense } from 'react';
import { Caveat, Inter } from 'next/font/google';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
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';
@@ -19,32 +22,35 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
export function generateMetadata() {
return {
title: {
template: '%s - Documenso',
default: 'Documenso',
},
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
keywords:
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' },
robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative',
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
type: 'website',
images: ['/opengraph-image.jpg'],
},
twitter: {
site: '@documenso',
card: 'summary_large_image',
images: ['/opengraph-image.jpg'],
description:
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
},
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags();
@@ -62,6 +68,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<PublicEnvScript />
</head>
<Suspense>

View File

@@ -4,6 +4,7 @@ import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
@@ -25,7 +26,7 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
return;
}
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
void copy(`${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.',

View File

@@ -238,7 +238,7 @@ export const TransferTeamDialog = ({
<Alert variant="neutral">
<AlertDescription>
<ul className="list-outside list-disc space-y-2 pl-4">
{IS_BILLING_ENABLED && (
{IS_BILLING_ENABLED() && (
// Temporary removed.
// <li>
// {form.getValues('clearPaymentMethods')

View File

@@ -48,7 +48,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button>
</Link>
{IS_BILLING_ENABLED && (
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button
variant="ghost"

View File

@@ -56,7 +56,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button>
</Link>
{IS_BILLING_ENABLED && (
{IS_BILLING_ENABLED() && (
<Link href={billingPath}>
<Button
variant="ghost"

View File

@@ -67,7 +67,7 @@ export const TeamsMemberPageDataTable = ({
<Tabs value={currentTab} className="flex-shrink-0 overflow-x-auto">
<TabsList>
<TabsTrigger className="min-w-[60px]" value="members" asChild>
<Link href={pathname ?? '/'}>All</Link>
<Link href={pathname ?? '/'}>Active</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="invites" asChild>

View File

@@ -0,0 +1,95 @@
'use client';
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSendConfirmationEmailFormSchema = z.object({
email: z.string().email().min(1),
});
export type TSendConfirmationEmailFormSchema = z.infer<typeof ZSendConfirmationEmailFormSchema>;
export type SendConfirmationEmailFormProps = {
className?: string;
};
export const SendConfirmationEmailForm = ({ className }: SendConfirmationEmailFormProps) => {
const { toast } = useToast();
const form = useForm<TSendConfirmationEmailFormSchema>({
values: {
email: '',
},
resolver: zodResolver(ZSendConfirmationEmailFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: sendConfirmationEmail } = trpc.profile.sendConfirmationEmail.useMutation();
const onFormSubmit = async ({ email }: TSendConfirmationEmailFormSchema) => {
try {
await sendConfirmationEmail({ email });
toast({
title: 'Confirmation email sent',
description:
'A confirmation email has been sent, and it should arrive in your inbox shortly.',
duration: 5000,
});
form.reset();
} catch (err) {
toast({
title: 'An error occurred while sending your confirmation email',
description: 'Please try again and make sure you enter the correct email address.',
variant: 'destructive',
});
}
};
return (
<Form {...form}>
<form
className={cn('mt-6 flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email address</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
</FormItem>
)}
/>
<FormMessage />
<Button size="lg" type="submit" disabled={isSubmitting} loading={isSubmitting}>
Send confirmation email
</Button>
</fieldset>
</form>
</Form>
);
};

View File

@@ -2,6 +2,8 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -38,6 +40,8 @@ const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
'This account appears to be using a social login method, please sign in using that method',
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
[ErrorCode.UNVERIFIED_EMAIL]:
'This account has not been verified. Please verify your account before signing in.',
};
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
@@ -63,6 +67,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const { toast } = useToast();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const router = useRouter();
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
@@ -130,6 +135,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const errorMessage = ERROR_MESSAGES[result.error];
if (result.error === ErrorCode.UNVERIFIED_EMAIL) {
router.push(`/unverified-account`);
toast({
title: 'Unable to sign in',
description: errorMessage ?? 'An unknown error occurred',
});
return;
}
toast({
variant: 'destructive',
title: 'Unable to sign in',

View File

@@ -1,5 +1,7 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
@@ -55,6 +57,7 @@ export type SignUpFormProps = {
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast();
const analytics = useAnalytics();
const router = useRouter();
const form = useForm<TSignUpFormSchema>({
values: {
@@ -74,10 +77,13 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
try {
await signup({ name, email, password, signature });
await signIn('credentials', {
email,
password,
callbackUrl: SIGN_UP_REDIRECT_PATH,
router.push(`/unverified-account`);
toast({
title: 'Registration Successful',
description:
'You have successfully registered. Please verify your account by clicking on the link you received in the email.',
duration: 5000,
});
analytics.capture('App: User Sign Up', {

View File

@@ -1,3 +1,5 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
/**
* getAssetBuffer is used to retrieve array buffers for various assets
* that are hosted in the `public` folder.
@@ -8,7 +10,7 @@
* @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';
const baseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
};

View File

@@ -1,7 +1,7 @@
/** @type {import('lint-staged').Config} */
module.exports = {
'**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`,
'**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`,
'**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`,
'**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`),
'**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*/package.json': 'npm run precommit',
};

16
package-lock.json generated
View File

@@ -9,6 +9,9 @@
"apps/*",
"packages/*"
],
"dependencies": {
"next-runtime-env": "^3.2.0"
},
"devDependencies": {
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
@@ -14404,6 +14407,19 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/next-runtime-env": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/next-runtime-env/-/next-runtime-env-3.2.0.tgz",
"integrity": "sha512-rwe3flUgSRm51hzRN4Vt5MMSYMS4aDMEPJa0r+CMONA3UyUZl8Y5O8zjHSIlaNb3yquTCttZ0ahObPyPprBj9g==",
"dependencies": {
"next": "^14",
"react": "^18"
},
"peerDependencies": {
"next": "^14",
"react": "^18"
}
},
"node_modules/next-themes": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz",

View File

@@ -47,7 +47,9 @@
"apps/*",
"packages/*"
],
"dependencies": {},
"dependencies": {
"next-runtime-env": "^3.2.0"
},
"overrides": {
"next-auth": {
"next": "14.0.3"

View File

@@ -12,7 +12,7 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
const url = new URL('/api/limits', APP_BASE_URL() ?? 'http://localhost:3000');
if (teamId) {
requestHeaders['team-id'] = teamId.toString();

View File

@@ -1,11 +1,10 @@
import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { getPricesByPlan } from '../stripe/get-prices-by-plan';
import { getDocumentRelatedPrices } from '../stripe/get-document-related-prices.ts';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
@@ -16,7 +15,7 @@ export type GetServerLimitsOptions = {
};
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
if (!IS_BILLING_ENABLED) {
if (!IS_BILLING_ENABLED()) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
@@ -56,10 +55,11 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
);
if (activeSubscriptions.length > 0) {
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY);
const documentPlanPrices = await getDocumentRelatedPrices();
for (const subscription of activeSubscriptions) {
const price = communityPlanPrices.find((price) => price.id === subscription.priceId);
const price = documentPlanPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}

View File

@@ -0,0 +1,10 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of documents a user can create.
*/
export const getDocumentRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};

View File

@@ -0,0 +1,13 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
export const getEnterprisePlanPrices = async () => {
return await getPricesByPlan(STRIPE_PLAN_TYPE.ENTERPRISE);
};
export const getEnterprisePlanPriceIds = async () => {
const prices = await getEnterprisePlanPrices();
return prices.map((price) => price.id);
};

View File

@@ -1,14 +1,18 @@
import type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByPlan = async (
plan: (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE],
) => {
type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
export const getPricesByPlan = async (plan: PlanType | PlanType[]) => {
const planTypes = typeof plan === 'string' ? [plan] : plan;
const query = planTypes.map((planType) => `metadata['plan']:'${planType}'`).join(' OR ');
const { data: prices } = await stripe.prices.search({
query: `metadata['plan']:'${plan}' type:'recurring'`,
query,
expand: ['data.product'],
limit: 100,
});
return prices;
return prices.filter((price) => price.type === 'recurring');
};

View File

@@ -0,0 +1,10 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the prices of items that count as the account's primary plan.
*/
export const getPrimaryAccountPlanPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};

View File

@@ -0,0 +1,17 @@
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getPricesByPlan } from './get-prices-by-plan';
/**
* Returns the Stripe prices of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPrices = async () => {
return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]);
};
/**
* Returns the Stripe price IDs of items that affect the amount of teams a user can create.
*/
export const getTeamRelatedPriceIds = async () => {
return await getTeamRelatedPrices().then((prices) => prices.map((price) => price.id));
};

View File

@@ -2,13 +2,13 @@ import type Stripe from 'stripe';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getCommunityPlanPriceIds } from './get-community-plan-prices';
import { getTeamPrices } from './get-team-prices';
import { getTeamRelatedPriceIds } from './get-team-related-prices';
type TransferStripeSubscriptionOptions = {
/**
@@ -46,14 +46,14 @@ export const transferTeamSubscription = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
}
const [communityPlanIds, teamSeatPrices] = await Promise.all([
getCommunityPlanPriceIds(),
const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
getTeamRelatedPriceIds(),
getTeamPrices(),
]);
const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan(
const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
user.Subscription,
communityPlanIds,
teamRelatedPlanPriceIds,
);
let teamSubscription: Stripe.Subscription | null = null;

View File

@@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
import { Button, Column, Img, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@@ -10,7 +12,9 @@ export const TemplateDocumentSelfSigned = ({
documentName,
assetBaseUrl,
}: TemplateDocumentSelfSignedProps) => {
const signUpUrl = `${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();

View File

@@ -1,3 +1,5 @@
import { env } from 'next-runtime-env';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@@ -8,6 +10,8 @@ export interface TemplateResetPasswordProps {
}
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -24,7 +28,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
>
Sign In
</Button>

View File

@@ -0,0 +1,13 @@
import type { EffectCallback } from 'react';
import { useEffect } from 'react';
/**
* Dangerously runs an effect "once" by ignoring the depedencies of a given effect.
*
* DANGER: The effect will run twice in concurrent react and development environments.
*/
export const unsafe_useEffectOnce = (callback: EffectCallback) => {
// Intentionally avoiding exhaustive deps and rule of hooks here
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/rules-of-hooks
return useEffect(callback, []);
};

View File

@@ -1,16 +1,19 @@
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
import { env } from 'next-runtime-env';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
export const NEXT_PUBLIC_WEBAPP_URL = () => env('NEXT_PUBLIC_WEBAPP_URL');
export const NEXT_PUBLIC_MARKETING_URL = () => env('NEXT_PUBLIC_MARKETING_URL');
export const APP_BASE_URL = IS_APP_WEB
? process.env.NEXT_PUBLIC_WEBAPP_URL
: process.env.NEXT_PUBLIC_MARKETING_URL;
export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true';
export const WEBAPP_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000';
export const APP_FOLDER = () => (IS_APP_MARKETING ? 'marketing' : 'web');
export const MARKETING_BASE_URL = process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001';
export const APP_BASE_URL = () =>
IS_APP_WEB ? NEXT_PUBLIC_WEBAPP_URL() : NEXT_PUBLIC_MARKETING_URL();
export const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000';
export const MARKETING_BASE_URL = NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001';

View File

@@ -6,6 +6,5 @@ export enum STRIPE_CUSTOMER_TYPE {
export enum STRIPE_PLAN_TYPE {
TEAM = 'team',
COMMUNITY = 'community',
ENTERPRISE = 'enterprise',
}
export const TEAM_BILLING_DOMAIN = 'billing.team.documenso.com';

View File

@@ -1,5 +1,10 @@
import { env } from 'next-runtime-env';
import { APP_BASE_URL } from './app';
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
/**
* The flag name for global session recording feature flag.
*/
@@ -16,8 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account.
*/
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true',
app_teams: true,
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
marketing_header_single_player_mode: false,
} as const;
@@ -25,8 +29,8 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
* Extract the PostHog configuration from the environment.
*/
export function extractPostHogConfig(): { key: string; host: string } | null {
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const postHogHost = `${APP_BASE_URL}/ingest`;
const postHogKey = NEXT_PUBLIC_POSTHOG_KEY();
const postHogHost = `${APP_BASE_URL()}/ingest`;
if (!postHogKey || !postHogHost) {
return null;

View File

@@ -6,4 +6,4 @@ export const DEFAULT_HANDWRITING_FONT_SIZE = 50;
export const MIN_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20;
export const CAVEAT_FONT_PATH = `${APP_BASE_URL}/fonts/caveat.ttf`;
export const CAVEAT_FONT_PATH = () => `${APP_BASE_URL()}/fonts/caveat.ttf`;

View File

@@ -7,13 +7,16 @@ import type { JWT } from 'next-auth/jwt';
import CredentialsProvider from 'next-auth/providers/credentials';
import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google';
import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { ErrorCode } from './error-codes';
@@ -90,6 +93,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}
}
if (!user.emailVerified) {
const mostRecentToken = await getMostRecentVerificationTokenByUserId({
userId: user.id,
});
if (
!mostRecentToken ||
mostRecentToken.expires.valueOf() <= Date.now() ||
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
await sendConfirmationToken({ email });
}
throw new Error(ErrorCode.UNVERIFIED_EMAIL);
}
return {
id: Number(user.id),
email: user.email,
@@ -203,7 +222,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
async signIn({ user }) {
// We do this to stop OAuth providers from creating an account
// when signups are disabled
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') {
const userData = await getUserByEmail({ email: user.email! });
return !!userData;

View File

@@ -19,4 +19,5 @@ export const ErrorCode = {
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL',
} as const;

View File

@@ -43,7 +43,7 @@ export const setupTwoFactorAuthentication = async ({
const secret = crypto.randomBytes(10);
const backupCodes = new Array(10)
const backupCodes = Array.from({ length: 10 })
.fill(null)
.map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());

View File

@@ -5,11 +5,16 @@ import { render } from '@documenso/email/render';
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendConfirmationEmailProps {
userId: number;
}
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
const NEXT_PRIVATE_SMTP_FROM_NAME = process.env.NEXT_PRIVATE_SMTP_FROM_NAME;
const NEXT_PRIVATE_SMTP_FROM_ADDRESS = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -30,10 +35,10 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
throw new Error('Verification token not found for the user');
}
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl,

View File

@@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { ForgotPasswordTemplate } from '@documenso/email/templates/forgot-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendForgotPasswordOptions {
userId: number;
}
@@ -29,8 +31,8 @@ export const sendForgotPassword = async ({ userId }: SendForgotPasswordOptions)
}
const token = user.PasswordResetToken[0].token;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const resetPasswordLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/reset-password/${token}`;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const resetPasswordLink = `${NEXT_PUBLIC_WEBAPP_URL()}/reset-password/${token}`;
const template = createElement(ForgotPasswordTemplate, {
assetBaseUrl,

View File

@@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendResetPasswordOptions {
userId: number;
}
@@ -16,7 +18,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
},
});
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(ResetPasswordTemplate, {
assetBaseUrl,

View File

@@ -8,6 +8,7 @@ import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
export type DeleteDocumentOptions = {
@@ -49,7 +50,7 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio
if (document.Recipient.length > 0) {
await Promise.all(
document.Recipient.map(async (recipient) => {
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
documentName: document.title,

View File

@@ -18,6 +18,8 @@ import type { Prisma } from '@documenso/prisma/client';
import { getDocumentWhereInput } from './get-document-by-id';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export type ResendDocumentOptions = {
documentId: number;
userId: number;
@@ -94,8 +96,8 @@ export const resendDocument = async ({
'document.name': document.title,
};
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,

View File

@@ -5,6 +5,7 @@ import { render } from '@documenso/email/render';
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFile } from '../../universal/upload/get-file';
@@ -40,12 +41,12 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
document.Recipient.map(async (recipient) => {
const { email, name, token } = recipient;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCompletedEmailTemplate, {
documentName: document.title,
assetBaseUrl,
downloadLink: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${token}/complete`,
downloadLink: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${token}/complete`,
});
await prisma.$transaction(async (tx) => {

View File

@@ -4,10 +4,6 @@ import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '@documenso/lib/constants/recipient-roles';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
@@ -15,6 +11,12 @@ import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-em
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import {
RECIPIENT_ROLES_DESCRIPTION,
RECIPIENT_ROLE_TO_EMAIL_TYPE,
} from '../../constants/recipient-roles';
export type SendDocumentOptions = {
documentId: number;
userId: number;
@@ -91,8 +93,8 @@ export const sendDocument = async ({
'document.name': document.title,
};
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
const template = createElement(DocumentInviteEmailTemplate, {
documentName: document.title,

View File

@@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendPendingEmailOptions {
documentId: number;
recipientId: number;
@@ -41,7 +43,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
const { email, name } = recipient;
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentPendingEmailTemplate, {
documentName: document.title,

View File

@@ -5,6 +5,7 @@ import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
/**
@@ -38,11 +39,11 @@ export default async function handlerFeatureFlagAll(req: Request) {
const origin = req.headers.get('origin');
if (origin) {
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
if (origin.startsWith(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}

View File

@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid';
import { JWT, getToken } from 'next-auth/jwt';
import type { JWT } from 'next-auth/jwt';
import { getToken } from 'next-auth/jwt';
import { LOCAL_FEATURE_FLAGS, extractPostHogConfig } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
/**
* Evaluate a single feature flag based on the current user if possible.
*
@@ -57,11 +60,11 @@ export default async function handleFeatureFlagGet(req: Request) {
const origin = req.headers.get('Origin');
if (origin) {
if (origin.startsWith(process.env.NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000')) {
if (origin.startsWith(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
if (origin.startsWith(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001')) {
if (origin.startsWith(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001')) {
res.headers.set('Access-Control-Allow-Origin', origin);
}
}

View File

@@ -20,6 +20,7 @@ export interface SetFieldsForDocumentOptions {
pageY: number;
pageWidth: number;
pageHeight: number;
label: string;
}[];
requestMetadata?: RequestMetadata;
}

View File

@@ -12,7 +12,7 @@ export async function insertTextInPDF(
useHandwritingFont = true,
): Promise<string> {
// Fetch the font file from the public URL.
const fontResponse = await fetch(CAVEAT_FONT_PATH);
const fontResponse = await fetch(CAVEAT_FONT_PATH());
const fontCaveat = await fontResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(pdfAsBase64);

View File

@@ -46,7 +46,7 @@ export const acceptTeamInvitation = async ({ userId, teamId }: AcceptTeamInvitat
},
});
if (IS_BILLING_ENABLED && team.subscription) {
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId: teamMemberInvite.teamId,

View File

@@ -12,7 +12,7 @@ export const createTeamBillingPortal = async ({
userId,
teamId,
}: CreateTeamBillingPortalOptions) => {
if (!IS_BILLING_ENABLED) {
if (!IS_BILLING_ENABLED()) {
throw new Error('Billing is not enabled');
}

View File

@@ -2,11 +2,11 @@ import type Stripe from 'stripe';
import { z } from 'zod';
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer';
import { getCommunityPlanPriceIds } from '@documenso/ee/server-only/stripe/get-community-plan-prices';
import { getTeamRelatedPrices } from '@documenso/ee/server-only/stripe/get-team-related-prices';
import { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { subscriptionsContainsActiveCommunityPlan } from '@documenso/lib/utils/billing';
import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { Prisma, TeamMemberRole } from '@documenso/prisma/client';
@@ -57,17 +57,16 @@ export const createTeam = async ({
},
});
let isPaymentRequired = IS_BILLING_ENABLED;
let isPaymentRequired = IS_BILLING_ENABLED();
let customerId: string | null = null;
if (IS_BILLING_ENABLED) {
const communityPlanPriceIds = await getCommunityPlanPriceIds();
isPaymentRequired = !subscriptionsContainsActiveCommunityPlan(
user.Subscription,
communityPlanPriceIds,
if (IS_BILLING_ENABLED()) {
const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
prices.map((price) => price.id),
);
isPaymentRequired = !subscriptionsContainsActivePlan(user.Subscription, teamRelatedPriceIds);
customerId = await createTeamCustomer({
name: user.name ?? teamName,
email: user.email,

View File

@@ -85,7 +85,7 @@ export const deleteTeamMembers = async ({
},
});
if (IS_BILLING_ENABLED && team.subscription) {
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,

View File

@@ -72,8 +72,20 @@ export const getTeamByUrl = async ({ userId, teamUrl }: GetTeamByUrlOptions) =>
where: whereFilter,
include: {
teamEmail: true,
emailVerification: true,
transferVerification: true,
emailVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
transferVerification: {
select: {
expiresAt: true,
name: true,
email: true,
},
},
subscription: true,
members: {
where: {

View File

@@ -42,7 +42,7 @@ export const leaveTeam = async ({ userId, teamId }: LeaveTeamOptions) => {
},
});
if (IS_BILLING_ENABLED && team.subscription) {
if (IS_BILLING_ENABLED() && team.subscription) {
const numberOfSeats = await tx.teamMember.count({
where: {
teamId,

View File

@@ -49,7 +49,7 @@ export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOpti
let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED) {
if (IS_BILLING_ENABLED()) {
teamSubscription = await transferTeamSubscription({
user: newOwnerUser,
team,

View File

@@ -68,7 +68,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
},
});
if (!IS_BILLING_ENABLED) {
if (!IS_BILLING_ENABLED()) {
return;
}
@@ -108,7 +108,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
);
// Update the user record with a new or existing Stripe customer record.
if (IS_BILLING_ENABLED) {
if (IS_BILLING_ENABLED()) {
try {
return await getStripeCustomerByUser(user).then((session) => session.user);
} catch (err) {

View File

@@ -0,0 +1,18 @@
import { prisma } from '@documenso/prisma';
export type GetMostRecentVerificationTokenByUserIdOptions = {
userId: number;
};
export const getMostRecentVerificationTokenByUserId = async ({
userId,
}: GetMostRecentVerificationTokenByUserIdOptions) => {
return await prisma.verificationToken.findFirst({
where: {
userId,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@@ -1,13 +1,20 @@
import crypto from 'crypto';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
const IDENTIFIER = 'confirmation-email';
export const sendConfirmationToken = async ({ email }: { email: string }) => {
type SendConfirmationTokenOptions = { email: string; force?: boolean };
export const sendConfirmationToken = async ({
email,
force = false,
}: SendConfirmationTokenOptions) => {
const token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({
@@ -20,6 +27,21 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
throw new Error('User not found');
}
if (user.emailVerified) {
throw new Error('Email verified');
}
const mostRecentToken = await getMostRecentVerificationTokenByUserId({ userId: user.id });
// If we've sent a token in the last 5 minutes, don't send another one
if (
!force &&
mostRecentToken?.createdAt &&
DateTime.fromJSDate(mostRecentToken.createdAt).diffNow('minutes').minutes > -5
) {
return;
}
const createdToken = await prisma.verificationToken.create({
data: {
identifier: IDENTIFIER,
@@ -37,5 +59,11 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
throw new Error(`Failed to create the verification token`);
}
return sendConfirmationEmail({ userId: user.id });
try {
await sendConfirmationEmail({ userId: user.id });
return { success: true };
} catch (err) {
throw new Error(`Failed to send the confirmation email`);
}
};

View File

@@ -1,4 +1,6 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const getBaseUrl = () => {
if (typeof window !== 'undefined') {
return '';
@@ -8,8 +10,10 @@ export const getBaseUrl = () => {
return `https://${process.env.VERCEL_URL}`;
}
if (process.env.NEXT_PUBLIC_WEBAPP_URL) {
return process.env.NEXT_PUBLIC_WEBAPP_URL;
const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
if (webAppUrl) {
return webAppUrl;
}
return `http://localhost:${process.env.PORT ?? 3000}`;

View File

@@ -22,7 +22,7 @@ export const getFlag = async (
return LOCAL_FEATURE_FLAGS[flag] ?? true;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/get`);
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/get`);
url.searchParams.set('flag', flag);
const response = await fetch(url, {
@@ -55,7 +55,7 @@ export const getAllFlags = async (
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/all`);
return fetch(url, {
headers: {
@@ -80,7 +80,7 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
return LOCAL_FEATURE_FLAGS;
}
const url = new URL(`${APP_BASE_URL}/api/feature-flag/all`);
const url = new URL(`${APP_BASE_URL()}/api/feature-flag/all`);
return fetch(url, {
next: {

View File

@@ -1,4 +1,5 @@
import { base64 } from '@scure/base';
import { env } from 'next-runtime-env';
import { match } from 'ts-pattern';
import { DocumentDataType } from '@documenso/prisma/client';
@@ -12,7 +13,9 @@ type File = {
};
export const putFile = async (file: File) => {
const { type, data } = await match(process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT)
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
const { type, data } = await match(NEXT_PUBLIC_UPLOAD_TRANSPORT)
.with('s3', async () => putFileInS3(file))
.otherwise(async () => putFileInDatabase(file));

View File

@@ -11,6 +11,7 @@ import {
} from '@aws-sdk/client-s3';
import slugify from '@sindresorhus/slugify';
import { type JWT, getToken } from 'next-auth/jwt';
import { env } from 'next-runtime-env';
import path from 'node:path';
import { APP_BASE_URL } from '../../constants/app';
@@ -25,8 +26,10 @@ export const getPresignPostUrl = async (fileName: string, contentType: string) =
let token: JWT | null = null;
try {
const baseUrl = APP_BASE_URL() ?? 'http://localhost:3000';
token = await getToken({
req: new NextRequest(APP_BASE_URL ?? 'http://localhost:3000', {
req: new NextRequest(baseUrl, {
headers: headers(),
}),
});
@@ -117,7 +120,9 @@ export const deleteS3File = async (key: string) => {
};
const getS3Client = () => {
if (process.env.NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
const NEXT_PUBLIC_UPLOAD_TRANSPORT = env('NEXT_PUBLIC_UPLOAD_TRANSPORT');
if (NEXT_PUBLIC_UPLOAD_TRANSPORT !== 's3') {
throw new Error('Invalid upload transport');
}

View File

@@ -2,15 +2,14 @@ import type { Subscription } from '.prisma/client';
import { SubscriptionStatus } from '.prisma/client';
/**
* Returns true if there is a subscription that is active and is a community plan.
* Returns true if there is a subscription that is active and is one of the provided price IDs.
*/
export const subscriptionsContainsActiveCommunityPlan = (
export const subscriptionsContainsActivePlan = (
subscriptions: Subscription[],
communityPlanPriceIds: string[],
priceIds: string[],
) => {
return subscriptions.some(
(subscription) =>
subscription.status === SubscriptionStatus.ACTIVE &&
communityPlanPriceIds.includes(subscription.priceId),
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
);
};

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Field" ADD COLUMN "label" TEXT;

View File

@@ -210,15 +210,15 @@ model DocumentData {
}
model DocumentMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
}
enum ReadStatus {
@@ -283,6 +283,7 @@ model Field {
documentId Int?
templateId Int?
recipientId Int?
label String?
type FieldType
page Int
positionX Decimal @default(0)

View File

@@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server';
import { env } from 'next-runtime-env';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
@@ -8,10 +9,12 @@ import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-conf
import { authenticatedProcedure, procedure, router } from '../trpc';
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
const NEXT_PUBLIC_DISABLE_SIGNUP = () => env('NEXT_PUBLIC_DISABLE_SIGNUP');
export const authRouter = router({
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
try {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
if (NEXT_PUBLIC_DISABLE_SIGNUP() === 'true') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Signups are disabled.',

View File

@@ -33,6 +33,7 @@ export const fieldRouter = router({
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
label: field.label,
})),
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});

View File

@@ -15,6 +15,7 @@ export const ZAddFieldsMutationSchema = z.object({
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
label: z.string(),
}),
),
});

View File

@@ -141,7 +141,7 @@ export const profileRouter = router({
try {
const { email } = input;
return sendConfirmationToken({ email });
return await sendConfirmationToken({ email });
} catch (err) {
let message = 'We were unable to send a confirmation email. Please try again.';

View File

@@ -5,6 +5,7 @@ import { PDFDocument } from 'pdf-lib';
import { mailer } from '@documenso/email/mailer';
import { renderAsync } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
@@ -149,7 +150,7 @@ export const singleplayerRouter = router({
const template = createElement(DocumentSelfSignedEmailTemplate, {
documentName: documentName,
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000',
});
const [html, text] = await Promise.all([

View File

@@ -7,6 +7,7 @@ import { Copy, Sparkles } from 'lucide-react';
import { FaXTwitter } from 'react-icons/fa6';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import {
TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS,
@@ -68,7 +69,7 @@ export const DocumentShareButton = ({
const onCopyClick = async () => {
if (shareLink) {
await copyShareLink(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${shareLink.slug}`);
await copyShareLink(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${shareLink.slug}`);
} else {
await createAndCopyShareLink({
token,
@@ -92,7 +93,7 @@ export const DocumentShareButton = ({
}
// Ensuring we've prewarmed the opengraph image for the Twitter
await fetch(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}/opengraph`, {
await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}/opengraph`, {
// We don't care about the response, so we can use no-cors
mode: 'no-cors',
});
@@ -100,7 +101,7 @@ export const DocumentShareButton = ({
window.open(
generateTwitterIntent(
`I just ${token ? 'signed' : 'sent'} a document in style with @documenso. Check it out!`,
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`,
`${NEXT_PUBLIC_WEBAPP_URL()}/share/${slug}`,
),
'_blank',
);
@@ -148,7 +149,7 @@ export const DocumentShareButton = ({
'animate-pulse': !shareLink?.slug,
})}
>
{process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'}
{NEXT_PUBLIC_WEBAPP_URL()}/share/{shareLink?.slug || '...'}
</span>
<div
className={cn(
@@ -160,7 +161,7 @@ export const DocumentShareButton = ({
>
{shareLink?.slug && (
<img
src={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${shareLink.slug}/opengraph`}
src={`${NEXT_PUBLIC_WEBAPP_URL()}/share/${shareLink.slug}/opengraph`}
alt="sharing link"
className="h-full w-full object-cover"
/>

View File

@@ -121,7 +121,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}

View File

@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
import { Label } from '@radix-ui/react-label';
import { Check, ChevronsUpDown, Info } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
@@ -20,6 +21,7 @@ import { cn } from '../../lib/utils';
import { Button } from '../button';
import { Card, CardContent } from '../card';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
import { Input } from '../input';
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
import { useStep } from '../stepper';
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
@@ -32,6 +34,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { FieldItem } from './field-item';
import type { TDocumentFlowFormSchema } from './types';
import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
@@ -47,6 +50,8 @@ const DEFAULT_WIDTH_PERCENT = 15;
const MIN_HEIGHT_PX = 60;
const MIN_WIDTH_PX = 200;
type ActiveField = TDocumentFlowFormSchema['fields'][0];
export type AddFieldsFormProps = {
documentFlow: DocumentFlowStep;
hideRecipients?: boolean;
@@ -69,6 +74,7 @@ export const AddFieldsFormPartial = ({
control,
handleSubmit,
formState: { isSubmitting },
setValue,
} = useForm<TAddFieldsFormSchema>({
defaultValues: {
fields: fields.map((field) => ({
@@ -82,11 +88,23 @@ export const AddFieldsFormPartial = ({
pageHeight: Number(field.height),
signerEmail:
recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '',
label: field.label ?? '',
})),
},
});
// const addLabelForm = useForm<TAddCustomLabelFormSchema>({
// defaultValues: {
// label: '',
// },
// });
// const onCustomLabelFormSubmit = (data: TAddCustomLabelFormSchema) => {
// console.log('Custom label', data);
// };
const onFormSubmit = handleSubmit(onSubmit);
// const onAddCustomLabelFormSubmit = addLabelForm.handleSubmit(onCustomLabelFormSubmit);
const {
append,
@@ -101,6 +119,8 @@ export const AddFieldsFormPartial = ({
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
const [selectedSigner, setSelectedSigner] = useState<Recipient | null>(null);
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const [activeField, setActiveField] = useState<ActiveField | null>(null);
const [fieldLabel, setFieldLabel] = useState<Record<string, string> | null>({});
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
@@ -186,12 +206,13 @@ export const AddFieldsFormPartial = ({
pageWidth: fieldPageWidth,
pageHeight: fieldPageHeight,
signerEmail: selectedSigner.email,
label: activeField?.label ?? fieldLabel?.[activeField?.formId ?? ''] ?? '',
});
setIsFieldWithinBounds(false);
setSelectedField(null);
},
[append, isWithinPageBounds, selectedField, selectedSigner, getPage],
[append, isWithinPageBounds, selectedField, selectedSigner, activeField, fieldLabel, getPage],
);
const onFieldResize = useCallback(
@@ -257,7 +278,7 @@ export const AddFieldsFormPartial = ({
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseClick);
};
}, [onMouseClick, onMouseMove, selectedField]);
}, [onMouseClick, onMouseMove, selectedField, activeField]);
useEffect(() => {
const observer = new MutationObserver((_mutations) => {
@@ -311,6 +332,8 @@ export const AddFieldsFormPartial = ({
);
}, [recipientsByRole]);
console.log(localFields[0].label);
return (
<>
<DocumentFlowFormContainerHeader
@@ -342,19 +365,24 @@ export const AddFieldsFormPartial = ({
</Card>
)}
{localFields.map((field, index) => (
<FieldItem
key={index}
field={field}
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
))}
{localFields.map((field, index) => {
return (
<FieldItem
key={index}
field={field}
disabled={selectedSigner?.email !== field.signerEmail || hasSelectedSignerBeenSent}
minHeight={fieldBounds.current.height}
minWidth={fieldBounds.current.width}
passive={isFieldWithinBounds && !!selectedField}
onResize={(options) => onFieldResize(options, index)}
onClick={(field) => {
setActiveField(field);
}}
onMove={(options) => onFieldMove(options, index)}
onRemove={() => remove(index)}
/>
);
})}
{!hideRecipients && (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
@@ -380,7 +408,7 @@ export const AddFieldsFormPartial = ({
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<Command value={selectedSigner?.email}>
<CommandInput />
<CommandEmpty>
@@ -462,7 +490,7 @@ export const AddFieldsFormPartial = ({
</Popover>
)}
<div className="-mx-2 flex-1 overflow-y-auto px-2">
<div className="-mx-2 flex-1 px-2">
<fieldset disabled={isFieldsDisabled} className="grid grid-cols-2 gap-x-4 gap-y-8">
<button
type="button"
@@ -554,6 +582,40 @@ export const AddFieldsFormPartial = ({
</button>
</fieldset>
</div>
{activeField && (
<div className="-mx-2 my-8 flex-1 gap-1.5 px-2">
<Label htmlFor="form-label">Custom Label</Label>
<div className="mt-2 flex w-full items-center space-x-2">
<Input
type="text"
className="w-full"
placeholder="Label"
id="form-label"
value={fieldLabel?.[activeField.formId] ?? activeField.label}
onChange={(e) => {
setFieldLabel((prev) => ({
...prev,
[activeField.formId]: e.target.value,
}));
setValue(
'fields',
localFields.map((field) => {
if (field.formId === activeField.formId) {
return {
...field,
label: fieldLabel?.[activeField.formId] ?? activeField.label ?? '',
};
}
return field;
}),
);
}}
/>
</div>
</div>
)}
</div>
</DocumentFlowFormContainerContent>

View File

@@ -14,6 +14,7 @@ export const ZAddFieldsFormSchema = z.object({
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
label: z.string().min(1),
}),
),
});

Some files were not shown because too many files have changed in this diff Show More