Merge branch 'main' into feat/separate-document-page

This commit is contained in:
David Nguyen
2024-02-22 16:13:33 +11:00
100 changed files with 836 additions and 56918 deletions

View File

@ -1,4 +1,16 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.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 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' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'
date: 2024-01-25 date: 2024-01-25
Tags: tags:
- Vision - Vision
- Mission - Mission
- Open Source - Open Source

View File

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

View File

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

@ -5,14 +5,13 @@ import { allDocuments } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types'; import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks'; import { useMDXComponent } from 'next-contentlayer/hooks';
export const generateStaticParams = () => export const dynamic = 'force-dynamic';
allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
export const generateMetadata = ({ params }: { params: { content: string } }) => { export const generateMetadata = ({ params }: { params: { content: string } }) => {
const document = allDocuments.find((post) => post._raw.flattenedPath === params.content); const document = allDocuments.find((doc) => doc._raw.flattenedPath === params.content);
if (!document) { if (!document) {
notFound(); return { title: 'Not Found' };
} }
return { title: document.title }; return { title: document.title };

View File

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

View File

@ -5,6 +5,7 @@ import { allBlogPosts } from 'contentlayer/generated';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Blog', title: 'Blog',
}; };
export default function BlogPage() { export default function BlogPage() {
const blogPosts = allBlogPosts.sort((a, b) => { const blogPosts = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.date); const dateA = new Date(a.date);

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; 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 { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import type { Field, Recipient } from '@documenso/prisma/client'; 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"> <p className="text-foreground mx-auto mt-4 max-w-[50ch] text-lg leading-normal">
Create a{' '} Create a{' '}
<Link <Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank" target="_blank"
className="hover:text-foreground/80 font-semibold transition-colors" 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 revalidate = 0;
export const dynamic = 'force-dynamic';
// !: This entire file is a hack to get around failed prerendering of // !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during // !: 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 { Caveat, Inter } from 'next/font/google';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; 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 { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
import { TrpcProvider } from '@documenso/trpc/react'; import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -17,7 +18,8 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = { export function generateMetadata() {
return {
title: { title: {
template: '%s - Documenso', template: '%s - Documenso',
default: 'Documenso', default: 'Documenso',
@ -28,21 +30,23 @@ export const metadata = {
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates', 'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' }, authors: { name: 'Documenso, Inc.' },
robots: 'index, follow', robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3000'),
openGraph: { openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative', title: 'Documenso - The Open Source DocuSign Alternative',
description: 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.', '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', type: 'website',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`], images: ['/opengraph-image.jpg'],
}, },
twitter: { twitter: {
site: '@documenso', site: '@documenso',
card: 'summary_large_image', card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_MARKETING_URL}/opengraph-image.jpg`], images: ['/opengraph-image.jpg'],
description: 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.', '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 }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getAllAnonymousFlags(); const flags = await getAllAnonymousFlags();

View File

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

View File

@ -6,6 +6,7 @@ import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; 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 type { Signature } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; 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"> <p className="text-muted-foreground/60 mt-16 text-center text-sm">
Create a{' '} Create a{' '}
<Link <Link
href={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/signup`} href={`${NEXT_PUBLIC_WEBAPP_URL()}/signup`}
target="_blank" target="_blank"
className="text-documenso-700 hover:text-documenso-600 whitespace-nowrap" 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 { AnimatePresence, motion } from 'framer-motion';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { usePlausible } from 'next-plausible'; import { usePlausible } from 'next-plausible';
import { env } from 'next-runtime-env';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@ -144,7 +145,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
setTimeout(resolve, 1000); 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 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 { 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 { redis } from '@documenso/lib/server-only/redis';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; 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( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
@ -40,7 +42,7 @@ export default async function handler(
if (user) { if (user) {
return res.status(200).json({ 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', mode: 'subscription',
metadata, metadata,
allow_promotion_codes: true, allow_promotion_codes: true,
success_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}/claimed?sessionId={CHECKOUT_SESSION_ID}`, success_url: `${NEXT_PUBLIC_MARKETING_URL()}/claimed?sessionId={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_MARKETING_URL}`, cancel_url: `${NEXT_PUBLIC_MARKETING_URL()}`,
}); });
if (!checkout.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,7 +108,6 @@ export const ResendDocumentActionItem = ({
}; };
return ( return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}> <DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
@ -190,6 +189,5 @@ export const ResendDocumentActionItem = ({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</>
); );
}; };

View File

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

View File

@ -2,6 +2,7 @@
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; 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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
export const createBillingPortal = async () => { export const createBillingPortal = async () => {
@ -11,6 +12,6 @@ export const createBillingPortal = async () => {
return getPortalSession({ return getPortalSession({
customerId: stripeCustomer.id, 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 { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer'; import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; 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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id'; 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) { if (foundSubscription) {
return getPortalSession({ return getPortalSession({
customerId: stripeCustomer.id, customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/billing`,
}); });
} }
return getCheckoutSession({ return getCheckoutSession({
customerId: stripeCustomer.id, customerId: stripeCustomer.id,
priceId, 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 { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; 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 { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; 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); 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 }), getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ plan: STRIPE_PLAN_TYPE.COMMUNITY }), 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; let subscriptionProduct: Stripe.Product | null = null;
const communityPlanUserSubscriptions = subscriptions.filter(({ priceId }) => const primaryAccountPlanSubscriptions = subscriptions.filter(({ priceId }) =>
communityPlanPriceIds.includes(priceId), primaryAccountPlanPriceIds.includes(priceId),
); );
const subscription = const subscription =
communityPlanUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ?? primaryAccountPlanSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
communityPlanUserSubscriptions[0]; primaryAccountPlanSubscriptions[0];
if (subscription?.priceId) { if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( 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 { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { ShareHandlerAPIResponse } from '~/pages/api/share'; import type { ShareHandlerAPIResponse } from '~/pages/api/share';
export const runtime = 'edge'; 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( const recipientOrSender: ShareHandlerAPIResponse = await fetch(
new URL(`/api/share?slug=${slug}`, baseUrl), 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 { headers } from 'next/headers';
import { redirect } from 'next/navigation'; 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 = { type SharePageProps = {
params: { slug: string }; params: { slug: string };
@ -16,12 +16,12 @@ export function generateMetadata({ params: { slug } }: SharePageProps) {
title: 'Documenso - Join the open source signing revolution', title: 'Documenso - Join the open source signing revolution',
description: 'I just signed with Documenso!', description: 'I just signed with Documenso!',
type: 'website', type: 'website',
images: [`${APP_BASE_URL}/share/${slug}/opengraph`], images: [`/share/${slug}/opengraph`],
}, },
twitter: { twitter: {
site: '@documenso', site: '@documenso',
card: 'summary_large_image', card: 'summary_large_image',
images: [`${APP_BASE_URL}/share/${slug}/opengraph`], images: [`/share/${slug}/opengraph`],
description: 'I just signed with Documenso!', description: 'I just signed with Documenso!',
}, },
} satisfies Metadata; } satisfies Metadata;
@ -35,5 +35,5 @@ export default function SharePage() {
return null; return null;
} }
redirect(process.env.NEXT_PUBLIC_MARKETING_URL ?? 'http://localhost:3001'); redirect(NEXT_PUBLIC_MARKETING_URL() ?? 'http://localhost:3001');
} }

View File

@ -2,6 +2,8 @@ import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
@ -18,6 +20,8 @@ type SignInPageProps = {
}; };
export default function SignInPage({ searchParams }: 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 rawEmail = typeof searchParams.email === 'string' ? searchParams.email : undefined;
const email = rawEmail ? decryptSecondaryData(rawEmail) : null; const email = rawEmail ? decryptSecondaryData(rawEmail) : null;
@ -39,7 +43,7 @@ export default function SignInPage({ searchParams }: SignInPageProps) {
isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} 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"> <p className="text-muted-foreground mt-6 text-center text-sm">
Don't have an account?{' '} Don't have an account?{' '}
<Link href="/signup" className="text-primary duration-200 hover:opacity-70"> <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 Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { env } from 'next-runtime-env';
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth'; import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt'; import { decryptSecondaryData } from '@documenso/lib/server-only/crypto/decrypt';
@ -18,7 +20,9 @@ type SignUpPageProps = {
}; };
export default function SignUpPage({ searchParams }: 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'); 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 { Caveat, Inter } from 'next/font/google';
import { PublicEnvScript } from 'next-runtime-env';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { LocaleProvider } from '@documenso/lib/client-only/providers/locale'; 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 { getServerComponentAllFlags } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { getLocale } from '@documenso/lib/server-only/headers/get-locale'; import { getLocale } from '@documenso/lib/server-only/headers/get-locale';
import { TrpcProvider } from '@documenso/trpc/react'; import { TrpcProvider } from '@documenso/trpc/react';
@ -19,7 +22,8 @@ import './globals.css';
const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' }); const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' }); const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
export const metadata = { export function generateMetadata() {
return {
title: { title: {
template: '%s - Documenso', template: '%s - Documenso',
default: 'Documenso', default: 'Documenso',
@ -30,21 +34,23 @@ export const metadata = {
'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates', 'Documenso, open source, DocuSign alternative, document signing, open signing infrastructure, open-source community, fast signing, beautiful signing, smart templates',
authors: { name: 'Documenso, Inc.' }, authors: { name: 'Documenso, Inc.' },
robots: 'index, follow', robots: 'index, follow',
metadataBase: new URL(NEXT_PUBLIC_WEBAPP_URL() ?? 'http://localhost:3000'),
openGraph: { openGraph: {
title: 'Documenso - The Open Source DocuSign Alternative', title: 'Documenso - The Open Source DocuSign Alternative',
description: 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.', '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', type: 'website',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`], images: ['/opengraph-image.jpg'],
}, },
twitter: { twitter: {
site: '@documenso', site: '@documenso',
card: 'summary_large_image', card: 'summary_large_image',
images: [`${process.env.NEXT_PUBLIC_WEBAPP_URL}/opengraph-image.jpg`], images: ['/opengraph-image.jpg'],
description: 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.', '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 }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags(); 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="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<PublicEnvScript />
</head> </head>
<Suspense> <Suspense>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,12 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { flushSync } from 'react-dom';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
import { z } from 'zod'; import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@ -54,14 +52,16 @@ export const EnableAuthenticatorAppDialog = ({
open, open,
onOpenChange, onOpenChange,
}: EnableAuthenticatorAppDialogProps) => { }: EnableAuthenticatorAppDialogProps) => {
const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } = const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
trpc.twoFactorAuthentication.setup.useMutation(); trpc.twoFactorAuthentication.setup.useMutation();
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } = const {
trpc.twoFactorAuthentication.enable.useMutation(); mutateAsync: enableTwoFactorAuthentication,
data: enableTwoFactorAuthenticationData,
isLoading: isEnableTwoFactorAuthenticationDataLoading,
} = trpc.twoFactorAuthentication.enable.useMutation();
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({ const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
defaultValues: { defaultValues: {
@ -115,6 +115,19 @@ export const EnableAuthenticatorAppDialog = ({
} }
}; };
const downloadRecoveryCodes = () => {
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
type: 'text/plain',
});
downloadFile({
filename: 'documenso-2FA-recovery-codes.txt',
data: blob,
});
}
};
const onEnableTwoFactorAuthenticationFormSubmit = async ({ const onEnableTwoFactorAuthenticationFormSubmit = async ({
token, token,
}: TEnableTwoFactorAuthenticationForm) => { }: TEnableTwoFactorAuthenticationForm) => {
@ -136,14 +149,6 @@ export const EnableAuthenticatorAppDialog = ({
} }
}; };
const onCompleteClick = () => {
flushSync(() => {
onOpenChange(false);
});
router.refresh();
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl"> <DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
@ -270,9 +275,16 @@ export const EnableAuthenticatorAppDialog = ({
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} /> <RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
)} )}
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between"> <div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button type="button" onClick={() => onCompleteClick()}> <Button onClick={() => onOpenChange(false)}>Complete</Button>
Complete
<Button
variant="secondary"
onClick={downloadRecoveryCodes}
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
loading={isEnableTwoFactorAuthenticationDataLoading}
>
Download
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
@ -42,8 +43,11 @@ export type ViewRecoveryCodesDialogProps = {
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => { export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } = const {
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); mutateAsync: viewRecoveryCodes,
data: viewRecoveryCodesData,
isLoading: isViewRecoveryCodesDataLoading,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({ const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: { defaultValues: {
@ -62,6 +66,19 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
return 'view'; return 'view';
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]); }, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
const downloadRecoveryCodes = () => {
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
type: 'text/plain',
});
downloadFile({
filename: 'documenso-2FA-recovery-codes.txt',
data: blob,
});
}
};
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => { const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
try { try {
await viewRecoveryCodes({ password }); await viewRecoveryCodes({ password });
@ -139,8 +156,17 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} /> <RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)} )}
<div className="mt-4 flex flex-row-reverse items-center justify-between"> <div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button onClick={() => onOpenChange(false)}>Complete</Button> <Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
disabled={!viewRecoveryCodesData?.recoveryCodes}
loading={isViewRecoveryCodesDataLoading}
onClick={downloadRecoveryCodes}
>
Download
</Button>
</div> </div>
</div> </div>
)) ))

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 { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form'; 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', '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_CODE]: 'The two-factor authentication code provided is incorrect',
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup 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; const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
@ -63,6 +67,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const { toast } = useToast(); const { toast } = useToast();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false); useState(false);
const router = useRouter();
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup' 'totp' | 'backup'
@ -130,6 +135,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
const errorMessage = ERROR_MESSAGES[result.error]; 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({ toast({
variant: 'destructive', variant: 'destructive',
title: 'Unable to sign in', title: 'Unable to sign in',

View File

@ -1,5 +1,7 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -55,6 +57,7 @@ export type SignUpFormProps = {
export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => { export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignUpFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const analytics = useAnalytics(); const analytics = useAnalytics();
const router = useRouter();
const form = useForm<TSignUpFormSchema>({ const form = useForm<TSignUpFormSchema>({
values: { values: {
@ -74,10 +77,13 @@ export const SignUpForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
try { try {
await signup({ name, email, password, signature }); await signup({ name, email, password, signature });
await signIn('credentials', { router.push(`/unverified-account`);
email,
password, toast({
callbackUrl: SIGN_UP_REDIRECT_PATH, 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', { 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 * getAssetBuffer is used to retrieve array buffers for various assets
* that are hosted in the `public` folder. * that are hosted in the `public` folder.
@ -8,7 +10,7 @@
* @param path The path to the asset, relative to the `public` folder. * @param path The path to the asset, relative to the `public` folder.
*/ */
export const getAssetBuffer = async (path: string) => { 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()); return fetch(new URL(path, baseUrl)).then(async (res) => res.arrayBuffer());
}; };

View File

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

16
package-lock.json generated
View File

@ -9,6 +9,9 @@
"apps/*", "apps/*",
"packages/*" "packages/*"
], ],
"dependencies": {
"next-runtime-env": "^3.2.0"
},
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^17.7.1", "@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0", "@commitlint/config-conventional": "^17.7.0",
@ -14404,6 +14407,19 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" "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": { "node_modules/next-themes": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.2.1.tgz",

View File

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

View File

@ -12,7 +12,7 @@ export type GetLimitsOptions = {
export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => { export const getLimits = async ({ headers, teamId }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {}; 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) { if (teamId) {
requestHeaders['team-id'] = teamId.toString(); requestHeaders['team-id'] = teamId.toString();

View File

@ -1,11 +1,10 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client'; 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 { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS, TEAM_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors'; import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema'; import { ZLimitsSchema } from './schema';
@ -16,7 +15,7 @@ export type GetServerLimitsOptions = {
}; };
export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => { export const getServerLimits = async ({ email, teamId }: GetServerLimitsOptions) => {
if (!IS_BILLING_ENABLED) { if (!IS_BILLING_ENABLED()) {
return { return {
quota: SELFHOSTED_PLAN_LIMITS, quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS, remaining: SELFHOSTED_PLAN_LIMITS,
@ -56,10 +55,11 @@ const handleUserLimits = async ({ email }: HandleUserLimitsOptions) => {
); );
if (activeSubscriptions.length > 0) { if (activeSubscriptions.length > 0) {
const communityPlanPrices = await getPricesByPlan(STRIPE_PLAN_TYPE.COMMUNITY); const documentPlanPrices = await getDocumentRelatedPrices();
for (const subscription of activeSubscriptions) { 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) { if (!price || typeof price.product === 'string' || price.product.deleted) {
continue; 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 type { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByPlan = async ( type PlanType = (typeof STRIPE_PLAN_TYPE)[keyof typeof STRIPE_PLAN_TYPE];
plan: (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({ const { data: prices } = await stripe.prices.search({
query: `metadata['plan']:'${plan}' type:'recurring'`, query,
expand: ['data.product'], expand: ['data.product'],
limit: 100, 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 { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe'; 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 { prisma } from '@documenso/prisma';
import { type Subscription, type Team, type User } from '@documenso/prisma/client'; import { type Subscription, type Team, type User } from '@documenso/prisma/client';
import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods'; import { deleteCustomerPaymentMethods } from './delete-customer-payment-methods';
import { getCommunityPlanPriceIds } from './get-community-plan-prices';
import { getTeamPrices } from './get-team-prices'; import { getTeamPrices } from './get-team-prices';
import { getTeamRelatedPriceIds } from './get-team-related-prices';
type TransferStripeSubscriptionOptions = { type TransferStripeSubscriptionOptions = {
/** /**
@ -46,14 +46,14 @@ export const transferTeamSubscription = async ({
throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.'); throw new AppError(AppErrorCode.NOT_FOUND, 'Missing customer ID.');
} }
const [communityPlanIds, teamSeatPrices] = await Promise.all([ const [teamRelatedPlanPriceIds, teamSeatPrices] = await Promise.all([
getCommunityPlanPriceIds(), getTeamRelatedPriceIds(),
getTeamPrices(), getTeamPrices(),
]); ]);
const teamSubscriptionRequired = !subscriptionsContainsActiveCommunityPlan( const teamSubscriptionRequired = !subscriptionsContainsActivePlan(
user.Subscription, user.Subscription,
communityPlanIds, teamRelatedPlanPriceIds,
); );
let teamSubscription: Stripe.Subscription | null = null; 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 { Button, Column, Img, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image'; import { TemplateDocumentImage } from './template-document-image';
@ -10,7 +12,9 @@ export const TemplateDocumentSelfSigned = ({
documentName, documentName,
assetBaseUrl, assetBaseUrl,
}: TemplateDocumentSelfSignedProps) => { }: 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) => { const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString(); 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 { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image'; import { TemplateDocumentImage } from './template-document-image';
@ -8,6 +10,8 @@ export interface TemplateResetPasswordProps {
} }
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => { export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
const NEXT_PUBLIC_WEBAPP_URL = env('NEXT_PUBLIC_WEBAPP_URL');
return ( return (
<> <>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} /> <TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@ -24,7 +28,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
<Section className="mb-6 mt-8 text-center"> <Section className="mb-6 mt-8 text-center">
<Button <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" 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 Sign In
</Button> </Button>

View File

@ -0,0 +1,19 @@
export type DownloadFileOptions = {
filename: string;
data: Blob;
};
export const downloadFile = ({ filename, data }: DownloadFileOptions) => {
if (typeof window === 'undefined') {
throw new Error('downloadFile can only be called in browser environments');
}
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(data);
link.download = filename;
link.click();
window.URL.revokeObjectURL(link.href);
};

View File

@ -1,6 +1,7 @@
import type { DocumentData } from '@documenso/prisma/client'; import type { DocumentData } from '@documenso/prisma/client';
import { getFile } from '../universal/upload/get-file'; import { getFile } from '../universal/upload/get-file';
import { downloadFile } from './download-file';
type DownloadPDFProps = { type DownloadPDFProps = {
documentData: DocumentData; documentData: DocumentData;
@ -14,16 +15,12 @@ export const downloadPDF = async ({ documentData, fileName }: DownloadPDFProps)
type: 'application/pdf', type: 'application/pdf',
}); });
const link = window.document.createElement('a');
const [baseTitle] = fileName?.includes('.pdf') const [baseTitle] = fileName?.includes('.pdf')
? fileName.split('.pdf') ? fileName.split('.pdf')
: [fileName ?? 'document']; : [fileName ?? 'document'];
link.href = window.URL.createObjectURL(blob); downloadFile({
link.download = `${baseTitle}_signed.pdf`; filename: baseTitle,
data: blob,
link.click(); });
window.URL.revokeObjectURL(link.href);
}; };

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'; import { env } from 'next-runtime-env';
export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
export const IS_BILLING_ENABLED = process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true';
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50; 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 export const IS_APP_MARKETING = process.env.NEXT_PUBLIC_PROJECT === 'marketing';
? process.env.NEXT_PUBLIC_WEBAPP_URL export const IS_APP_WEB = process.env.NEXT_PUBLIC_PROJECT === 'web';
: process.env.NEXT_PUBLIC_MARKETING_URL; 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 { export enum STRIPE_PLAN_TYPE {
TEAM = 'team', TEAM = 'team',
COMMUNITY = 'community', 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'; 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. * The flag name for global session recording feature flag.
*/ */
@ -16,7 +21,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
* Does not take any person or group properties into account. * Does not take any person or group properties into account.
*/ */
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = { export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
app_billing: process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED === 'true', app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
app_teams: true, app_teams: true,
app_document_page_view_history_sheet: false, app_document_page_view_history_sheet: false,
marketing_header_single_player_mode: false, marketing_header_single_player_mode: false,
@ -26,8 +31,8 @@ export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
* Extract the PostHog configuration from the environment. * Extract the PostHog configuration from the environment.
*/ */
export function extractPostHogConfig(): { key: string; host: string } | null { export function extractPostHogConfig(): { key: string; host: string } | null {
const postHogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; const postHogKey = NEXT_PUBLIC_POSTHOG_KEY();
const postHogHost = `${APP_BASE_URL}/ingest`; const postHogHost = `${APP_BASE_URL()}/ingest`;
if (!postHogKey || !postHogHost) { if (!postHogKey || !postHogHost) {
return null; 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_STANDARD_FONT_SIZE = 8;
export const MIN_HANDWRITING_FONT_SIZE = 20; 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 CredentialsProvider from 'next-auth/providers/credentials';
import type { GoogleProfile } from 'next-auth/providers/google'; import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google'; import GoogleProvider from 'next-auth/providers/google';
import { env } from 'next-runtime-env';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client'; import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; 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 { 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 { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { ErrorCode } from './error-codes'; 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 { return {
id: Number(user.id), id: Number(user.id),
email: user.email, email: user.email,
@ -203,7 +222,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
async signIn({ user }) { async signIn({ user }) {
// We do this to stop OAuth providers from creating an account // We do this to stop OAuth providers from creating an account
// when signups are disabled // 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! }); const userData = await getUserByEmail({ email: user.email! });
return !!userData; return !!userData;

View File

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

View File

@ -43,7 +43,7 @@ export const setupTwoFactorAuthentication = async ({
const secret = crypto.randomBytes(10); const secret = crypto.randomBytes(10);
const backupCodes = new Array(10) const backupCodes = Array.from({ length: 10 })
.fill(null) .fill(null)
.map(() => crypto.randomBytes(5).toString('hex')) .map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase()); .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 { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendConfirmationEmailProps { export interface SendConfirmationEmailProps {
userId: number; userId: number;
} }
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => { 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({ const user = await prisma.user.findFirstOrThrow({
where: { where: {
id: userId, id: userId,
@ -30,10 +35,10 @@ export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailPro
throw new Error('Verification token not found for the user'); 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 confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso'; const senderName = NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com'; const senderAdress = NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
const confirmationTemplate = createElement(ConfirmEmailTemplate, { const confirmationTemplate = createElement(ConfirmEmailTemplate, {
assetBaseUrl, assetBaseUrl,

View File

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

View File

@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password'; import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendResetPasswordOptions { export interface SendResetPasswordOptions {
userId: number; 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, { const template = createElement(ResetPasswordTemplate, {
assetBaseUrl, assetBaseUrl,

View File

@ -8,6 +8,7 @@ import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus } from '@documenso/prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email'; import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
@ -94,7 +95,7 @@ export const deleteDocument = async ({
if (document.Recipient.length > 0) { if (document.Recipient.length > 0) {
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { 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, { const template = createElement(DocumentCancelTemplate, {
documentName: document.title, documentName: document.title,

View File

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

View File

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

View File

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

View File

@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending'; import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
export interface SendPendingEmailOptions { export interface SendPendingEmailOptions {
documentId: number; documentId: number;
recipientId: number; recipientId: number;
@ -41,7 +43,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
const { email, name } = recipient; 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, { const template = createElement(DocumentPendingEmailTemplate, {
documentName: document.title, 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 { LOCAL_FEATURE_FLAGS } from '@documenso/lib/constants/feature-flags';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; 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'; import { extractDistinctUserId, mapJwtToFlagProperties } from './get';
/** /**
@ -38,11 +39,11 @@ export default async function handlerFeatureFlagAll(req: Request) {
const origin = req.headers.get('origin'); const origin = req.headers.get('origin');
if (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); 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); res.headers.set('Access-Control-Allow-Origin', origin);
} }
} }

View File

@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { nanoid } from 'nanoid'; 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 { 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 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. * 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'); const origin = req.headers.get('Origin');
if (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); 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); res.headers.set('Access-Control-Allow-Origin', origin);
} }
} }

View File

@ -12,7 +12,7 @@ export async function insertTextInPDF(
useHandwritingFont = true, useHandwritingFont = true,
): Promise<string> { ): Promise<string> {
// Fetch the font file from the public URL. // 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 fontCaveat = await fontResponse.arrayBuffer();
const pdfDoc = await PDFDocument.load(pdfAsBase64); 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({ const numberOfSeats = await tx.teamMember.count({
where: { where: {
teamId: teamMemberInvite.teamId, teamId: teamMemberInvite.teamId,

View File

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

View File

@ -2,11 +2,11 @@ import type Stripe from 'stripe';
import { z } from 'zod'; import { z } from 'zod';
import { createTeamCustomer } from '@documenso/ee/server-only/stripe/create-team-customer'; 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 { mapStripeSubscriptionToPrismaUpsertAction } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; 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 } from '@documenso/prisma';
import { Prisma, TeamMemberRole } from '@documenso/prisma/client'; 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; let customerId: string | null = null;
if (IS_BILLING_ENABLED) { if (IS_BILLING_ENABLED()) {
const communityPlanPriceIds = await getCommunityPlanPriceIds(); const teamRelatedPriceIds = await getTeamRelatedPrices().then((prices) =>
prices.map((price) => price.id),
isPaymentRequired = !subscriptionsContainsActiveCommunityPlan(
user.Subscription,
communityPlanPriceIds,
); );
isPaymentRequired = !subscriptionsContainsActivePlan(user.Subscription, teamRelatedPriceIds);
customerId = await createTeamCustomer({ customerId = await createTeamCustomer({
name: user.name ?? teamName, name: user.name ?? teamName,
email: user.email, 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({ const numberOfSeats = await tx.teamMember.count({
where: { where: {
teamId, teamId,

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({ const numberOfSeats = await tx.teamMember.count({
where: { where: {
teamId, teamId,

View File

@ -49,7 +49,7 @@ export const transferTeamOwnership = async ({ token }: TransferTeamOwnershipOpti
let teamSubscription: Stripe.Subscription | null = null; let teamSubscription: Stripe.Subscription | null = null;
if (IS_BILLING_ENABLED) { if (IS_BILLING_ENABLED()) {
teamSubscription = await transferTeamSubscription({ teamSubscription = await transferTeamSubscription({
user: newOwnerUser, user: newOwnerUser,
team, 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; 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. // Update the user record with a new or existing Stripe customer record.
if (IS_BILLING_ENABLED) { if (IS_BILLING_ENABLED()) {
try { try {
return await getStripeCustomerByUser(user).then((session) => session.user); return await getStripeCustomerByUser(user).then((session) => session.user);
} catch (err) { } 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 crypto from 'crypto';
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { ONE_HOUR } from '../../constants/time'; import { ONE_HOUR } from '../../constants/time';
import { sendConfirmationEmail } from '../auth/send-confirmation-email'; import { sendConfirmationEmail } from '../auth/send-confirmation-email';
import { getMostRecentVerificationTokenByUserId } from './get-most-recent-verification-token-by-user-id';
const IDENTIFIER = 'confirmation-email'; 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 token = crypto.randomBytes(20).toString('hex');
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
@ -20,6 +27,21 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
throw new Error('User not found'); 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({ const createdToken = await prisma.verificationToken.create({
data: { data: {
identifier: IDENTIFIER, identifier: IDENTIFIER,
@ -37,5 +59,11 @@ export const sendConfirmationToken = async ({ email }: { email: string }) => {
throw new Error(`Failed to create the verification token`); 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 */ /* eslint-disable turbo/no-undeclared-env-vars */
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
export const getBaseUrl = () => { export const getBaseUrl = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return ''; return '';
@ -8,8 +10,10 @@ export const getBaseUrl = () => {
return `https://${process.env.VERCEL_URL}`; return `https://${process.env.VERCEL_URL}`;
} }
if (process.env.NEXT_PUBLIC_WEBAPP_URL) { const webAppUrl = NEXT_PUBLIC_WEBAPP_URL();
return process.env.NEXT_PUBLIC_WEBAPP_URL;
if (webAppUrl) {
return webAppUrl;
} }
return `http://localhost:${process.env.PORT ?? 3000}`; return `http://localhost:${process.env.PORT ?? 3000}`;

View File

@ -22,7 +22,7 @@ export const getFlag = async (
return LOCAL_FEATURE_FLAGS[flag] ?? true; 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); url.searchParams.set('flag', flag);
const response = await fetch(url, { const response = await fetch(url, {
@ -55,7 +55,7 @@ export const getAllFlags = async (
return LOCAL_FEATURE_FLAGS; 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, { return fetch(url, {
headers: { headers: {
@ -80,7 +80,7 @@ export const getAllAnonymousFlags = async (): Promise<Record<string, TFeatureFla
return LOCAL_FEATURE_FLAGS; 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, { return fetch(url, {
next: { next: {

View File

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

View File

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

View File

@ -2,15 +2,14 @@ import type { Subscription } from '.prisma/client';
import { SubscriptionStatus } 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[], subscriptions: Subscription[],
communityPlanPriceIds: string[], priceIds: string[],
) => { ) => {
return subscriptions.some( return subscriptions.some(
(subscription) => (subscription) =>
subscription.status === SubscriptionStatus.ACTIVE && subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
communityPlanPriceIds.includes(subscription.priceId),
); );
}; };

View File

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

View File

@ -141,7 +141,7 @@ export const profileRouter = router({
try { try {
const { email } = input; const { email } = input;
return sendConfirmationToken({ email }); return await sendConfirmationToken({ email });
} catch (err) { } catch (err) {
let message = 'We were unable to send a confirmation email. Please try again.'; 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 { mailer } from '@documenso/email/mailer';
import { renderAsync } from '@documenso/email/render'; import { renderAsync } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed'; 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 { 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 { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id'; import { alphaid } from '@documenso/lib/universal/id';
@ -149,7 +150,7 @@ export const singleplayerRouter = router({
const template = createElement(DocumentSelfSignedEmailTemplate, { const template = createElement(DocumentSelfSignedEmailTemplate, {
documentName: documentName, 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([ 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 { FaXTwitter } from 'react-icons/fa6';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link'; import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { import {
TOAST_DOCUMENT_SHARE_ERROR, TOAST_DOCUMENT_SHARE_ERROR,
TOAST_DOCUMENT_SHARE_SUCCESS, TOAST_DOCUMENT_SHARE_SUCCESS,
@ -68,7 +69,7 @@ export const DocumentShareButton = ({
const onCopyClick = async () => { const onCopyClick = async () => {
if (shareLink) { if (shareLink) {
await copyShareLink(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${shareLink.slug}`); await copyShareLink(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${shareLink.slug}`);
} else { } else {
await createAndCopyShareLink({ await createAndCopyShareLink({
token, token,
@ -92,7 +93,7 @@ export const DocumentShareButton = ({
} }
// Ensuring we've prewarmed the opengraph image for the Twitter // 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 // We don't care about the response, so we can use no-cors
mode: 'no-cors', mode: 'no-cors',
}); });
@ -100,7 +101,7 @@ export const DocumentShareButton = ({
window.open( window.open(
generateTwitterIntent( generateTwitterIntent(
`I just ${token ? 'signed' : 'sent'} a document in style with @documenso. Check it out!`, `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', '_blank',
); );
@ -148,7 +149,7 @@ export const DocumentShareButton = ({
'animate-pulse': !shareLink?.slug, 'animate-pulse': !shareLink?.slug,
})} })}
> >
{process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'} {NEXT_PUBLIC_WEBAPP_URL()}/share/{shareLink?.slug || '...'}
</span> </span>
<div <div
className={cn( className={cn(
@ -160,7 +161,7 @@ export const DocumentShareButton = ({
> >
{shareLink?.slug && ( {shareLink?.slug && (
<img <img
src={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${shareLink.slug}/opengraph`} src={`${NEXT_PUBLIC_WEBAPP_URL()}/share/${shareLink.slug}/opengraph`}
alt="sharing link" alt="sharing link"
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />

View File

@ -121,7 +121,7 @@ const CommandItem = React.forwardRef<
<CommandPrimitive.Item <CommandPrimitive.Item
ref={ref} ref={ref}
className={cn( 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, className,
)} )}
{...props} {...props}

View File

@ -380,7 +380,7 @@ export const AddFieldsFormPartial = ({
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" align="start"> <PopoverContent className="p-0" align="start">
<Command> <Command value={selectedSigner?.email}>
<CommandInput /> <CommandInput />
<CommandEmpty> <CommandEmpty>

View File

@ -7,6 +7,8 @@ import { Undo2 } from 'lucide-react';
import type { StrokeOptions } from 'perfect-freehand'; import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand'; import { getStroke } from 'perfect-freehand';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { getSvgPathFromStroke } from './helper'; import { getSvgPathFromStroke } from './helper';
import { Point } from './point'; import { Point } from './point';
@ -28,6 +30,7 @@ export const SignaturePad = ({
...props ...props
}: SignaturePadProps) => { }: SignaturePadProps) => {
const $el = useRef<HTMLCanvasElement>(null); const $el = useRef<HTMLCanvasElement>(null);
const $imageData = useRef<ImageData | null>(null);
const [isPressed, setIsPressed] = useState(false); const [isPressed, setIsPressed] = useState(false);
const [lines, setLines] = useState<Point[][]>([]); const [lines, setLines] = useState<Point[][]>([]);
@ -134,7 +137,6 @@ export const SignaturePad = ({
}); });
onChange?.($el.current.toDataURL()); onChange?.($el.current.toDataURL());
ctx.save(); ctx.save();
} }
} }
@ -163,6 +165,7 @@ export const SignaturePad = ({
const ctx = $el.current.getContext('2d'); const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height); ctx?.clearRect(0, 0, $el.current.width, $el.current.height);
$imageData.current = null;
} }
onChange?.(null); onChange?.(null);
@ -176,19 +179,25 @@ export const SignaturePad = ({
return; return;
} }
const newLines = [...lines]; const newLines = lines.slice(0, -1);
newLines.pop(); // Remove the last line
setLines(newLines); setLines(newLines);
// Clear the canvas // Clear the canvas
if ($el.current) { if ($el.current) {
const ctx = $el.current.getContext('2d'); const ctx = $el.current.getContext('2d');
ctx?.clearRect(0, 0, $el.current.width, $el.current.height); const { width, height } = $el.current;
ctx?.clearRect(0, 0, width, height);
if (typeof defaultValue === 'string' && $imageData.current) {
ctx?.putImageData($imageData.current, 0, 0);
}
newLines.forEach((line) => { newLines.forEach((line) => {
const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions))); const pathData = new Path2D(getSvgPathFromStroke(getStroke(line, perfectFreehandOptions)));
ctx?.fill(pathData); ctx?.fill(pathData);
}); });
onChange?.($el.current.toDataURL());
} }
}; };
@ -199,7 +208,7 @@ export const SignaturePad = ({
} }
}, []); }, []);
useEffect(() => { unsafe_useEffectOnce(() => {
if ($el.current && typeof defaultValue === 'string') { if ($el.current && typeof defaultValue === 'string') {
const ctx = $el.current.getContext('2d'); const ctx = $el.current.getContext('2d');
@ -209,11 +218,15 @@ export const SignaturePad = ({
img.onload = () => { img.onload = () => {
ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height)); ctx?.drawImage(img, 0, 0, Math.min(width, img.width), Math.min(height, img.height));
const defaultImageData = ctx?.getImageData(0, 0, width, height) || null;
$imageData.current = defaultImageData;
}; };
img.src = defaultValue; img.src = defaultValue;
} }
}, [defaultValue]); });
return ( return (
<div <div

View File

@ -8,4 +8,5 @@ const pdfjsDistPath = path.dirname(require.resolve('pdfjs-dist/package.json'));
const pdfWorkerPath = path.join(pdfjsDistPath, 'build', 'pdf.worker.min.js'); const pdfWorkerPath = path.join(pdfjsDistPath, 'build', 'pdf.worker.min.js');
console.log(`Copying pdf.js to: ${path.resolve('./public/pdf.worker.min.js')}`);
fs.copyFileSync(pdfWorkerPath, './public/pdf.worker.min.js'); fs.copyFileSync(pdfWorkerPath, './public/pdf.worker.min.js');

View File

@ -0,0 +1,16 @@
#!/usr/bin/env node
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const fs = require('fs');
const wellKnownPath = path.join(__dirname, '../.well-known');
console.log('Copying .well-known/ contents to apps');
fs.cpSync(wellKnownPath, path.join(__dirname, '../apps/web/public/.well-known'), {
recursive: true,
});
fs.cpSync(wellKnownPath, path.join(__dirname, '../apps/marketing/public/.well-known'), {
recursive: true,
});