Merge branch 'main' into feat/document-auth

This commit is contained in:
David Nguyen
2024-03-25 23:06:46 +08:00
56 changed files with 4919 additions and 1306 deletions

View File

@ -1,13 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Start the database and mailserver
docker compose -f ./docker/compose-without-app.yml up -d
# Install dependencies # Install dependencies
npm install npm install
# Copy the env file # Copy the env file
cp .env.example .env cp .env.example .env
# Run the migrations # Run the dev setup
npm run prisma:migrate-dev npm run dx

View File

@ -22,10 +22,23 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool. # Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# [[E2E Tests]] # [[SIGNING]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User" # The transport to use for document signing. Available options: local (default) | gcloud-hsm
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" NEXT_PRIVATE_SIGNING_TRANSPORT="local"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" # OPTIONAL: The passphrase to use for the local file-based signing transport.
NEXT_PRIVATE_SIGNING_PASSPHRASE=
# OPTIONAL: The local file path to the .p12 file to use for the local signing transport.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the .p12 file to use for the local signing transport.
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud HSM key to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH=
# OPTIONAL: The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
# OPTIONAL: The base64-encoded contents of the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
# [[SIGNING]] # [[SIGNING]]
# OPTIONAL: Defines the signing transport to use. Available options: local (default) # OPTIONAL: Defines the signing transport to use. Available options: local (default)
@ -100,6 +113,11 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page. # OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP= NEXT_PUBLIC_DISABLE_SIGNUP=
# [[E2E Tests]]
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# This is only required for the marketing site # This is only required for the marketing site
# [[REDIS]] # [[REDIS]]
NEXT_PRIVATE_REDIS_URL= NEXT_PRIVATE_REDIS_URL=

View File

@ -1,7 +1,9 @@
{ {
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll": "explicit"
}, },
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
"javascript.preferences.importModuleSpecifier": "non-relative", "javascript.preferences.importModuleSpecifier": "non-relative",

View File

@ -21,7 +21,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
const config = { const config = {
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt'], serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },

View File

@ -38,7 +38,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
return ( return (
<div <div
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', { className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer', 'overflow-y-auto overflow-x-hidden':
pathname && !['/singleplayer', '/pricing'].includes(pathname),
})} })}
> >
<div <div

View File

@ -1,11 +1,10 @@
'use client'; 'use client';
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { formatMonth } from '@documenso/lib/client-only/format-month'; import { formatMonth } from '@documenso/lib/client-only/format-month';
import { cn } from '@documenso/ui/lib/utils';
export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & { export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & {
data: T; data: T;
@ -34,13 +33,13 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
.reverse(); .reverse();
return ( return (
<div className={cn('flex flex-col', className)} {...props}> <div className={className} {...props}>
<div className="flex items-center px-4"> <div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">{title}</h3> <h3 className="text-lg font-semibold">{title}</h3>
<span>{extraInfo}</span> <span>{extraInfo}</span>
</div> </div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={chartHeight}> <ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={formattedData}> <BarChart data={formattedData}>
<XAxis dataKey="month" /> <XAxis dataKey="month" />

View File

@ -5,8 +5,6 @@ import { useEffect, useState } from 'react';
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts'; import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
import { cn } from '@documenso/ui/lib/utils';
import { CAP_TABLE } from './data'; import { CAP_TABLE } from './data';
const COLORS = ['#7fd843', '#a2e771', '#c6f2a4']; const COLORS = ['#7fd843', '#a2e771', '#c6f2a4'];
@ -49,10 +47,12 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
setIsSSR(false); setIsSSR(false);
}, []); }, []);
return ( return (
<div className={cn('flex flex-col', className)} {...props}> <div className={className} {...props}>
<h3 className="px-4 text-lg font-semibold">Cap Table</h3> <div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Cap Table</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border shadow-sm hover:shadow">
{!isSSR && ( {!isSSR && (
<PieChart width={400} height={400}> <PieChart width={400} height={400}>
<Pie <Pie

View File

@ -1,11 +1,10 @@
'use client'; 'use client';
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { formatMonth } from '@documenso/lib/client-only/format-month'; import { formatMonth } from '@documenso/lib/client-only/format-month';
import { cn } from '@documenso/ui/lib/utils';
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & { export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
data: Record<string, string | number>[]; data: Record<string, string | number>[];
@ -18,10 +17,12 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
})); }));
return ( return (
<div className={cn('flex flex-col', className)} {...props}> <div className={className} {...props}>
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3> <div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Funding Raised</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}> <BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
<XAxis dataKey="date" /> <XAxis dataKey="date" />

View File

@ -0,0 +1,56 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
export type MonthlyCompletedDocumentsChartProps = {
className?: string;
data: GetUserMonthlyGrowthResult;
};
export const MonthlyCompletedDocumentsChart = ({
className,
data,
}: MonthlyCompletedDocumentsChartProps) => {
const formattedData = [...data].reverse().map(({ month, count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Completed Documents per Month</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Completed Documents']}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="Completed Documents"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyNewUsersChartProps = { export type MonthlyNewUsersChartProps = {
className?: string; className?: string;
@ -20,12 +19,12 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
}); });
return ( return (
<div className={cn('flex flex-col', className)}> <div className={className}>
<div className="flex items-center px-4"> <div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">New Users</h3> <h3 className="text-lg font-semibold">New Users</h3>
</div> </div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}> <BarChart data={formattedData}>
<XAxis dataKey="month" /> <XAxis dataKey="month" />

View File

@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils';
export type MonthlyTotalUsersChartProps = { export type MonthlyTotalUsersChartProps = {
className?: string; className?: string;
@ -20,12 +19,12 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
}); });
return ( return (
<div className={cn('flex flex-col', className)}> <div className={className}>
<div className="flex items-center px-4"> <div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Users</h3> <h3 className="text-lg font-semibold">Total Users</h3>
</div> </div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
<ResponsiveContainer width="100%" height={400}> <ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}> <BarChart data={formattedData}>
<XAxis dataKey="month" /> <XAxis dataKey="month" />

View File

@ -2,20 +2,23 @@ import type { Metadata } from 'next';
import { z } from 'zod'; import { z } from 'zod';
import { getCompletedDocumentsMonthly } from '@documenso/lib/server-only/user/get-monthly-completed-document';
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { FUNDING_RAISED } from '~/app/(marketing)/open/data'; import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
import { MetricCard } from '~/app/(marketing)/open/metric-card';
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
import { CallToAction } from '~/components/(marketing)/call-to-action'; import { CallToAction } from '~/components/(marketing)/call-to-action';
import { BarMetric } from './bar-metrics'; import { BarMetric } from './bar-metrics';
import { CapTable } from './cap-table'; import { CapTable } from './cap-table';
import { FundingRaised } from './funding-raised'; import { FundingRaised } from './funding-raised';
import { MetricCard } from './metric-card';
import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart';
import { MonthlyNewUsersChart } from './monthly-new-users-chart'; import { MonthlyNewUsersChart } from './monthly-new-users-chart';
import { MonthlyTotalUsersChart } from './monthly-total-users-chart'; import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
import { SalaryBands } from './salary-bands';
import { TeamMembers } from './team-members'; import { TeamMembers } from './team-members';
import { OpenPageTooltip } from './tooltip'; import { OpenPageTooltip } from './tooltip';
import { TotalSignedDocumentsChart } from './total-signed-documents-chart';
import { Typefully } from './typefully'; import { Typefully } from './typefully';
export const metadata: Metadata = { export const metadata: Metadata = {
@ -131,16 +134,18 @@ export default async function OpenPage() {
{ total_count: mergedPullRequests }, { total_count: mergedPullRequests },
STARGAZERS_DATA, STARGAZERS_DATA,
EARLY_ADOPTERS_DATA, EARLY_ADOPTERS_DATA,
MONTHLY_USERS,
MONTHLY_COMPLETED_DOCUMENTS,
] = await Promise.all([ ] = await Promise.all([
fetchGithubStats(), fetchGithubStats(),
fetchOpenIssues(), fetchOpenIssues(),
fetchMergedPullRequests(), fetchMergedPullRequests(),
fetchStargazers(), fetchStargazers(),
fetchEarlyAdopters(), fetchEarlyAdopters(),
getUserMonthlyGrowth(),
getCompletedDocumentsMonthly(),
]); ]);
const MONTHLY_USERS = await getUserMonthlyGrowth();
return ( return (
<div> <div>
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12"> <div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
@ -161,7 +166,7 @@ export default async function OpenPage() {
</p> </p>
</div> </div>
<div className="mt-12 grid grid-cols-12 gap-8"> <div className="my-12 grid grid-cols-12 gap-8">
<div className="col-span-12 grid grid-cols-4 gap-4"> <div className="col-span-12 grid grid-cols-4 gap-4">
<MetricCard <MetricCard
className="col-span-2 lg:col-span-1" className="col-span-2 lg:col-span-1"
@ -188,11 +193,57 @@ export default async function OpenPage() {
<TeamMembers className="col-span-12" /> <TeamMembers className="col-span-12" />
<SalaryBands className="col-span-12" /> <SalaryBands className="col-span-12" />
</div>
<h2 className="px-4 text-2xl font-semibold">Finances</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" /> <FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
<CapTable className="col-span-12 lg:col-span-6" /> <CapTable className="col-span-12 lg:col-span-6" />
</div>
<h2 className="px-4 text-2xl font-semibold">Community</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="stars"
title="GitHub: Total Stars"
label="Stars"
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="mergedPRs"
title="GitHub: Total Merged PRs"
label="Merged PRs"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="forks"
title="GitHub: Total Forks"
label="Forks"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="openIssues"
title="GitHub: Total Open Issues"
label="Open Issues"
chartHeight={400}
className="col-span-12 lg:col-span-6"
/>
<Typefully className="col-span-12 lg:col-span-6" />
</div>
<h2 className="px-4 text-2xl font-semibold">Growth</h2>
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
<BarMetric<EarlyAdoptersType> <BarMetric<EarlyAdoptersType>
data={EARLY_ADOPTERS_DATA} data={EARLY_ADOPTERS_DATA}
metricKey="earlyAdopters" metricKey="earlyAdopters"
@ -202,56 +253,28 @@ export default async function OpenPage() {
extraInfo={<OpenPageTooltip />} extraInfo={<OpenPageTooltip />}
/> />
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="stars"
title="Github: Total Stars"
label="Stars"
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="mergedPRs"
title="Github: Total Merged PRs"
label="Merged PRs"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="forks"
title="Github: Total Forks"
label="Forks"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<BarMetric<StargazersType>
data={STARGAZERS_DATA}
metricKey="openIssues"
title="Github: Total Open Issues"
label="Open Issues"
chartHeight={300}
className="col-span-12 lg:col-span-6"
/>
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" /> <MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" /> <MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
<Typefully className="col-span-12 lg:col-span-6" /> <MonthlyCompletedDocumentsChart
data={MONTHLY_COMPLETED_DOCUMENTS}
className="col-span-12 lg:col-span-6"
/>
<TotalSignedDocumentsChart
data={MONTHLY_COMPLETED_DOCUMENTS}
className="col-span-12 lg:col-span-6"
/>
</div>
</div>
<div className="col-span-12 mt-12 flex flex-col items-center justify-center"> <div className="col-span-12 mt-12 flex flex-col items-center justify-center">
<h2 className="text-2xl font-bold">Where's the rest?</h2> <h2 className="text-2xl font-bold">Is there more?</h2>
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal"> <p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
We're still working on getting all our metrics together. We'll update this page as This page is evolving as we learn what makes a great signing company. We'll update it when
soon as we have more to share. we have more to share.
</p> </p>
</div> </div>
</div>
</div>
<CallToAction className="mt-12" utmSource="open-page" /> <CallToAction className="mt-12" utmSource="open-page" />
</div> </div>

View File

@ -1,4 +1,4 @@
import { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { import {

View File

@ -0,0 +1,56 @@
'use client';
import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
export type TotalSignedDocumentsChartProps = {
className?: string;
data: GetUserMonthlyGrowthResult;
};
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
return {
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
count: Number(count),
};
});
return (
<div className={className}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Total Completed Documents</h3>
</div>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={formattedData}>
<XAxis dataKey="month" />
<YAxis />
<Tooltip
labelStyle={{
color: 'hsl(var(--primary-foreground))',
}}
formatter={(value) => [
Number(value).toLocaleString('en-US'),
'Total Completed Documents',
]}
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
/>
<Bar
dataKey="count"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
maxBarSize={60}
label="Total Completed Documents"
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -6,18 +6,19 @@ import Link from 'next/link';
import { FaXTwitter } from 'react-icons/fa6'; import { FaXTwitter } from 'react-icons/fa6';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
export type TypefullyProps = HTMLAttributes<HTMLDivElement>; export type TypefullyProps = HTMLAttributes<HTMLDivElement>;
export const Typefully = ({ className, ...props }: TypefullyProps) => { export const Typefully = ({ className, ...props }: TypefullyProps) => {
return ( return (
<div className={cn('flex flex-col', className)} {...props}> <div className={className} {...props}>
<h3 className="px-4 text-lg font-semibold">Twitter Stats</h3> <div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Twitter Stats</h3>
</div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border py-8 shadow-sm hover:shadow"> <div className="my-12 flex flex-col items-center gap-y-4 text-center">
<div className="flex flex-col items-center gap-y-4 text-center">
<FaXTwitter className="h-12 w-12" /> <FaXTwitter className="h-12 w-12" />
<Link href="https://typefully.com/documenso/stats" target="_blank"> <Link href="https://typefully.com/documenso/stats" target="_blank">
<h1>Documenso on X</h1> <h1>Documenso on X</h1>

View File

@ -53,7 +53,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
> >
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm"> <Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" /> <LuGithub className="mr-2 h-5 w-5" />
Star on Github Star on GitHub
{starCount && starCount > 0 && ( {starCount && starCount > 0 && (
<span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs"> <span className="bg-primary dark:text-background -mr-2.5 ml-2.5 rounded-full px-2 py-1.5 text-xs">
{starCount.toLocaleString('en-US')} {starCount.toLocaleString('en-US')}

View File

@ -123,7 +123,7 @@ export const Hero = ({ className, ...props }: HeroProps) => {
<Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}> <Link href="https://github.com/documenso/documenso" onClick={() => event('view-github')}>
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm"> <Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
<LuGithub className="mr-2 h-5 w-5" /> <LuGithub className="mr-2 h-5 w-5" />
Star on Github Star on GitHub
</Button> </Button>
</Link> </Link>
</motion.div> </motion.div>

View File

@ -23,7 +23,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
return ( return (
<div className={cn('', className)} {...props}> <div className={cn('', className)} {...props}>
<div className="flex items-center justify-center gap-x-6"> <div className="bg-background sticky top-32 flex items-center justify-end gap-x-6 shadow-[-1px_-5px_2px_6px_hsl(var(--background))] md:top-[7.5rem] lg:static lg:justify-center">
<AnimatePresence> <AnimatePresence>
<motion.button <motion.button
key="MONTHLY" key="MONTHLY"
@ -40,7 +40,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
{period === 'MONTHLY' && ( {period === 'MONTHLY' && (
<motion.div <motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID} layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full" className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/> />
)} )}
</motion.button> </motion.button>
@ -63,7 +63,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
{period === 'YEARLY' && ( {period === 'YEARLY' && (
<motion.div <motion.div
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID} layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full" className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
/> />
)} )}
</motion.button> </motion.button>

View File

@ -22,7 +22,7 @@ const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: { experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'), outputFileTracingRoot: path.join(__dirname, '../../'),
serverComponentsExternalPackages: ['@node-rs/bcrypt'], serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },

View File

@ -1,14 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes'; import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { PasswordForm } from '~/components/forms/password'; import { PasswordForm } from '~/components/forms/password';
export const metadata: Metadata = { export const metadata: Metadata = {
@ -25,11 +25,13 @@ export default async function SecuritySettingsPage() {
subtitle="Here you can manage your password and security settings." subtitle="Here you can manage your password and security settings."
/> />
{user.identityProvider === 'DOCUMENSO' ? ( {user.identityProvider === 'DOCUMENSO' && (
<div> <>
<PasswordForm user={user} /> <PasswordForm user={user} />
<hr className="border-border/50 mt-6" /> <hr className="border-border/50 mt-6" />
</>
)}
<Alert <Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
@ -39,12 +41,18 @@ export default async function SecuritySettingsPage() {
<AlertTitle>Two factor authentication</AlertTitle> <AlertTitle>Two factor authentication</AlertTitle>
<AlertDescription className="mr-4"> <AlertDescription className="mr-4">
Create one-time passwords that serve as a secondary authentication method for Add an authenticator to serve as a secondary authentication method{' '}
confirming your identity when requested during the sign-in process. {user.identityProvider === 'DOCUMENSO'
? 'when signing in, or when signing documents.'
: 'for signing documents.'}
</AlertDescription> </AlertDescription>
</div> </div>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} /> {user.twoFactorEnabled ? (
<DisableAuthenticatorAppDialog />
) : (
<EnableAuthenticatorAppDialog />
)}
</Alert> </Alert>
{user.twoFactorEnabled && ( {user.twoFactorEnabled && (
@ -56,26 +64,12 @@ export default async function SecuritySettingsPage() {
<AlertTitle>Recovery codes</AlertTitle> <AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4"> <AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the Two factor authentication recovery codes are used to access your account in the event
event that you lose access to your authenticator app. that you lose access to your authenticator app.
</AlertDescription> </AlertDescription>
</div> </div>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} /> <ViewRecoveryCodesDialog />
</Alert>
)}
</div>
) : (
<Alert className="p-6" variant="neutral">
<AlertTitle>
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</AlertTitle>
<AlertDescription>
To update your password, enable two-factor authentication, and manage other security
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
settings.
</AlertDescription>
</Alert> </Alert>
)} )}

View File

@ -1,43 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
type AuthenticatorAppProps = {
isTwoFactorEnabled: boolean;
};
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
const isEnableDialogOpen = modalState === 'enable';
const isDisableDialogOpen = modalState === 'disable';
return (
<>
<div className="flex-shrink-0">
{isTwoFactorEnabled ? (
<Button variant="destructive" onClick={() => setModalState('disable')}>
Disable 2FA
</Button>
) : (
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
)}
</div>
<EnableAuthenticatorAppDialog
open={isEnableDialogOpen}
onOpenChange={(open) => !open && setModalState(null)}
/>
<DisableAuthenticatorAppDialog
open={isDisableDialogOpen}
onOpenChange={(open) => !open && setModalState(null)}
/>
</>
);
};

View File

@ -1,3 +1,7 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
@ -9,65 +13,51 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel,
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisableTwoFactorAuthenticationForm = z.object({ export const ZDisable2FAForm = z.object({
password: z.string().min(6).max(72), token: z.string(),
backupCode: z.string(),
}); });
export type TDisableTwoFactorAuthenticationForm = z.infer< export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
typeof ZDisableTwoFactorAuthenticationForm
>;
export type DisableAuthenticatorAppDialogProps = { export const DisableAuthenticatorAppDialog = () => {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DisableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: DisableAuthenticatorAppDialogProps) => {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { mutateAsync: disableTwoFactorAuthentication } = const [isOpen, setIsOpen] = useState(false);
trpc.twoFactorAuthentication.disable.useMutation();
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({ const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
const disable2FAForm = useForm<TDisable2FAForm>({
defaultValues: { defaultValues: {
password: '', token: '',
backupCode: '',
}, },
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm), resolver: zodResolver(ZDisable2FAForm),
}); });
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } = const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
disableTwoFactorAuthenticationForm.formState;
const onDisableTwoFactorAuthenticationFormSubmit = async ({ const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => {
password,
backupCode,
}: TDisableTwoFactorAuthenticationForm) => {
try { try {
await disableTwoFactorAuthentication({ password, backupCode }); await disable2FA({ token });
toast({ toast({
title: 'Two-factor authentication disabled', title: 'Two-factor authentication disabled',
@ -76,7 +66,7 @@ export const DisableAuthenticatorAppDialog = ({
}); });
flushSync(() => { flushSync(() => {
onOpenChange(false); setIsOpen(false);
}); });
router.refresh(); router.refresh();
@ -91,74 +81,51 @@ export const DisableAuthenticatorAppDialog = ({
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl"> <DialogTrigger asChild={true}>
<Button className="flex-shrink-0" variant="destructive">
Disable 2FA
</Button>
</DialogTrigger>
<DialogContent position="center">
<DialogHeader> <DialogHeader>
<DialogTitle>Disable Authenticator App</DialogTitle> <DialogTitle>Disable 2FA</DialogTitle>
<DialogDescription> <DialogDescription>
To disable the Authenticator App for your account, please enter your password and a Please provide a token from the authenticator, or a backup code. If you do not have a
backup code. If you do not have a backup code available, please contact support. backup code available, please contact support.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Form {...disableTwoFactorAuthenticationForm}> <Form {...disable2FAForm}>
<form <form onSubmit={disable2FAForm.handleSubmit(onDisable2FAFormSubmit)}>
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit( <fieldset className="flex flex-col gap-y-4" disabled={isDisable2FASubmitting}>
onDisableTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<fieldset
className="flex flex-col gap-y-4"
disabled={isDisableTwoFactorAuthenticationSubmitting}
>
<FormField <FormField
name="password" name="token"
control={disableTwoFactorAuthenticationForm.control} control={disable2FAForm.control}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl> <FormControl>
<PasswordInput <Input {...field} placeholder="Token" />
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
name="backupCode"
control={disableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <DialogClose asChild>
<Button type="button" variant="secondary">
Cancel Cancel
</Button> </Button>
</DialogClose>
<Button <Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
type="submit"
variant="destructive"
loading={isDisableTwoFactorAuthenticationSubmitting}
>
Disable 2FA Disable 2FA
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset>
</form> </form>
</Form> </Form>
</DialogContent> </DialogContent>

View File

@ -1,8 +1,11 @@
import { useEffect, useMemo } from 'react'; 'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { renderSVG } from 'uqr'; import { renderSVG } from 'uqr';
import { z } from 'zod'; import { z } from 'zod';
@ -11,11 +14,13 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { import {
Form, Form,
@ -26,85 +31,60 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list'; import { RecoveryCodeList } from './recovery-code-list';
export const ZSetupTwoFactorAuthenticationForm = z.object({ export const ZEnable2FAForm = z.object({
password: z.string().min(6).max(72),
});
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
export const ZEnableTwoFactorAuthenticationForm = z.object({
token: z.string(), token: z.string(),
}); });
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>; export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
export type EnableAuthenticatorAppDialogProps = { export const EnableAuthenticatorAppDialog = () => {
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const EnableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: EnableAuthenticatorAppDialogProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter();
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } = const [isOpen, setIsOpen] = useState(false);
trpc.twoFactorAuthentication.setup.useMutation(); const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
const { const {
mutateAsync: enableTwoFactorAuthentication, mutateAsync: setup2FA,
data: enableTwoFactorAuthenticationData, data: setup2FAData,
isLoading: isEnableTwoFactorAuthenticationDataLoading, isLoading: isSettingUp2FA,
} = trpc.twoFactorAuthentication.enable.useMutation(); } = trpc.twoFactorAuthentication.setup.useMutation({
onError: () => {
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({ toast({
defaultValues: { title: 'Unable to setup two-factor authentication',
password: '', description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.',
variant: 'destructive',
});
}, },
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
}); });
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } = const enable2FAForm = useForm<TEnable2FAForm>({
setupTwoFactorAuthenticationForm.formState;
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
defaultValues: { defaultValues: {
token: '', token: '',
}, },
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm), resolver: zodResolver(ZEnable2FAForm),
}); });
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } = const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
enableTwoFactorAuthenticationForm.formState;
const step = useMemo(() => { const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
return 'setup';
}
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
return 'enable';
}
return 'view';
}, [
setupTwoFactorAuthenticationData,
isSetupTwoFactorAuthenticationSubmitting,
enableTwoFactorAuthenticationData,
isEnableTwoFactorAuthenticationSubmitting,
]);
const onSetupTwoFactorAuthenticationFormSubmit = async ({
password,
}: TSetupTwoFactorAuthenticationForm) => {
try { try {
await setupTwoFactorAuthentication({ password }); const data = await enable2FA({ code: token });
setRecoveryCodes(data.recoveryCodes);
toast({
title: 'Two-factor authentication enabled',
description:
'You will now be required to enter a code from your authenticator app when signing in.',
});
} catch (_err) { } catch (_err) {
toast({ toast({
title: 'Unable to setup two-factor authentication', title: 'Unable to setup two-factor authentication',
@ -116,8 +96,8 @@ export const EnableAuthenticatorAppDialog = ({
}; };
const downloadRecoveryCodes = () => { const downloadRecoveryCodes = () => {
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) { if (recoveryCodes) {
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], { const blob = new Blob([recoveryCodes.join('\n')], {
type: 'text/plain', type: 'text/plain',
}); });
@ -128,111 +108,80 @@ export const EnableAuthenticatorAppDialog = ({
} }
}; };
const onEnableTwoFactorAuthenticationFormSubmit = async ({ const handleEnable2FA = async () => {
token, if (!setup2FAData) {
}: TEnableTwoFactorAuthenticationForm) => { await setup2FA();
try {
await enableTwoFactorAuthentication({ code: token });
toast({
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
});
} catch (_err) {
toast({
title: 'Unable to setup two-factor authentication',
description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
} }
setIsOpen(true);
}; };
useEffect(() => { useEffect(() => {
// Reset the form when the Dialog closes enable2FAForm.reset();
if (!open) {
setupTwoFactorAuthenticationForm.reset(); if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null);
router.refresh();
} }
}, [open, setupTwoFactorAuthenticationForm]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl"> <DialogTrigger asChild={true}>
<Button
className="flex-shrink-0"
loading={isSettingUp2FA}
onClick={(e) => {
e.preventDefault();
void handleEnable2FA();
}}
>
Enable 2FA
</Button>
</DialogTrigger>
<DialogContent position="center">
{setup2FAData && (
<>
{recoveryCodes ? (
<div>
<DialogHeader> <DialogHeader>
<DialogTitle>Enable Authenticator App</DialogTitle> <DialogTitle>Backup codes</DialogTitle>
{step === 'setup' && (
<DialogDescription>
To enable two-factor authentication, please enter your password below.
</DialogDescription>
)}
{step === 'view' && (
<DialogDescription> <DialogDescription>
Your recovery codes are listed below. Please store them in a safe place. Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription> </DialogDescription>
)}
</DialogHeader> </DialogHeader>
{match(step) <div className="mt-4">
.with('setup', () => { <RecoveryCodeList recoveryCodes={recoveryCodes} />
return ( </div>
<Form {...setupTwoFactorAuthenticationForm}>
<form
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
onSetupTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={setupTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<PasswordInput
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter> <DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <DialogClose asChild>
Cancel <Button variant="secondary">Close</Button>
</Button> </DialogClose>
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}> <Button onClick={downloadRecoveryCodes}>Download</Button>
Continue
</Button>
</DialogFooter> </DialogFooter>
</form> </div>
</Form> ) : (
); <Form {...enable2FAForm}>
}) <form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
.with('enable', () => ( <DialogHeader>
<Form {...enableTwoFactorAuthenticationForm}> <DialogTitle>Enable Authenticator App</DialogTitle>
<form <DialogDescription>
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
onEnableTwoFactorAuthenticationFormSubmit,
)}
className="flex flex-col gap-y-4"
>
<p className="text-muted-foreground text-sm">
To enable two-factor authentication, scan the following QR code using your To enable two-factor authentication, scan the following QR code using your
authenticator app. authenticator app.
</p> </DialogDescription>
</DialogHeader>
<fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4">
<div <div
className="flex h-36 justify-center" className="flex h-36 justify-center"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''), __html: renderSVG(setup2FAData?.uri ?? ''),
}} }}
/> />
@ -242,7 +191,7 @@ export const EnableAuthenticatorAppDialog = ({
</p> </p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest"> <p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setupTwoFactorAuthenticationData?.secret} {setup2FAData?.secret}
</p> </p>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
@ -252,7 +201,7 @@ export const EnableAuthenticatorAppDialog = ({
<FormField <FormField
name="token" name="token"
control={enableTwoFactorAuthenticationForm.control} control={enable2FAForm.control}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel> <FormLabel className="text-muted-foreground">Token</FormLabel>
@ -265,38 +214,20 @@ export const EnableAuthenticatorAppDialog = ({
/> />
<DialogFooter> <DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <DialogClose asChild>
Cancel <Button variant="secondary">Cancel</Button>
</Button> </DialogClose>
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}> <Button type="submit" loading={isEnabling2FA}>
Enable 2FA Enable 2FA
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset>
</form> </form>
</Form> </Form>
))
.with('view', () => (
<div>
{enableTwoFactorAuthenticationData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
)} )}
</>
<div className="mt-4 flex flex-row-reverse items-center gap-2"> )}
<Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
onClick={downloadRecoveryCodes}
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
loading={isEnableTwoFactorAuthenticationDataLoading}
>
Download
</Button>
</div>
</div>
))
.exhaustive()}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -1,33 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
type RecoveryCodesProps = {
isTwoFactorEnabled: boolean;
};
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
className="flex-shrink-0"
onClick={() => setIsOpen(true)}
disabled={!isTwoFactorEnabled}
>
View Codes
</Button>
<ViewRecoveryCodesDialog
key={isOpen ? 'open' : 'closed'}
open={isOpen}
onOpenChange={setIsOpen}
/>
</>
);
};

View File

@ -1,4 +1,6 @@
import { useEffect, useMemo } from 'react'; 'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -6,69 +8,61 @@ import { match } from 'ts-pattern';
import { z } from 'zod'; import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file'; import { downloadFile } from '@documenso/lib/client-only/download-file';
import { AppError } from '@documenso/lib/errors/app-error';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { import {
Form, Form,
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel,
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list'; import { RecoveryCodeList } from './recovery-code-list';
export const ZViewRecoveryCodesForm = z.object({ export const ZViewRecoveryCodesForm = z.object({
password: z.string().min(6).max(72), token: z.string().min(1, { message: 'Token is required' }),
}); });
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>; export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
export type ViewRecoveryCodesDialogProps = { export const ViewRecoveryCodesDialog = () => {
open: boolean; const [isOpen, setIsOpen] = useState(false);
onOpenChange: (_open: boolean) => void;
};
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
const { toast } = useToast();
const { const {
mutateAsync: viewRecoveryCodes, data: recoveryCodes,
data: viewRecoveryCodesData, mutate,
isLoading: isViewRecoveryCodesDataLoading, isLoading,
isError,
error,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
// error?.data?.code
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({ const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: { defaultValues: {
password: '', token: '',
}, },
resolver: zodResolver(ZViewRecoveryCodesForm), resolver: zodResolver(ZViewRecoveryCodesForm),
}); });
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
const step = useMemo(() => {
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
return 'authenticate';
}
return 'view';
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
const downloadRecoveryCodes = () => { const downloadRecoveryCodes = () => {
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) { if (recoveryCodes) {
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], { const blob = new Blob([recoveryCodes.join('\n')], {
type: 'text/plain', type: 'text/plain',
}); });
@ -79,105 +73,88 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
} }
}; };
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
try {
await viewRecoveryCodes({ password });
} catch (_err) {
toast({
title: 'Unable to view recovery codes',
description:
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
variant: 'destructive',
});
}
};
useEffect(() => {
// Reset the form when the Dialog closes
if (!open) {
viewRecoveryCodesForm.reset();
}
}, [open, viewRecoveryCodesForm]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button className="flex-shrink-0">View Codes</Button>
</DialogTrigger>
<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">
<DialogHeader> {recoveryCodes ? (
<div>
<DialogHeader className="mb-4">
<DialogTitle>View Recovery Codes</DialogTitle> <DialogTitle>View Recovery Codes</DialogTitle>
{step === 'authenticate' && (
<DialogDescription>
To view your recovery codes, please enter your password below.
</DialogDescription>
)}
{step === 'view' && (
<DialogDescription> <DialogDescription>
Your recovery codes are listed below. Please store them in a safe place. Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription> </DialogDescription>
)}
</DialogHeader> </DialogHeader>
{match(step) <RecoveryCodeList recoveryCodes={recoveryCodes} />
.with('authenticate', () => {
return ( <DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
<Button onClick={downloadRecoveryCodes}>Download</Button>
</DialogFooter>
</div>
) : (
<Form {...viewRecoveryCodesForm}> <Form {...viewRecoveryCodesForm}>
<form <form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)} <DialogHeader className="mb-4">
className="flex flex-col gap-y-4" <DialogTitle>View Recovery Codes</DialogTitle>
>
<DialogDescription>
Please provide a token from your authenticator, or a backup code.
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isLoading}>
<FormField <FormField
name="password" name="token"
control={viewRecoveryCodesForm.control} control={viewRecoveryCodesForm.control}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl> <FormControl>
<PasswordInput <Input {...field} placeholder="Token" />
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<DialogFooter> {error && (
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}> <Alert variant="destructive">
Cancel <AlertDescription>
</Button> {match(AppError.parseError(error).message)
.with(
<Button type="submit" loading={isViewRecoveryCodesSubmitting}> ErrorCode.INCORRECT_TWO_FACTOR_CODE,
Continue () => 'Invalid code. Please try again.',
</Button> )
</DialogFooter> .otherwise(
</form> () => 'Something went wrong. Please try again or contact support.',
</Form> )}
); </AlertDescription>
}) </Alert>
.with('view', () => (
<div>
{viewRecoveryCodesData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)} )}
<div className="mt-4 flex flex-row-reverse items-center gap-2"> <DialogFooter>
<Button onClick={() => onOpenChange(false)}>Complete</Button> <DialogClose asChild>
<Button type="button" variant="secondary">
<Button Cancel
variant="secondary"
disabled={!viewRecoveryCodesData?.recoveryCodes}
loading={isViewRecoveryCodesDataLoading}
onClick={downloadRecoveryCodes}
>
Download
</Button> </Button>
</div> </DialogClose>
</div>
)) <Button type="submit" loading={isLoading}>
.exhaustive()} View
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -154,7 +154,7 @@ export const ClaimPublicProfileDialogForm = ({
<FormMessage /> <FormMessage />
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm"> <div className="bg-muted/50 text-muted-foreground mt-2 inline-block max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase">
{baseUrl.host}/u/{field.value || '<username>'} {baseUrl.host}/u/{field.value || '<username>'}
</div> </div>
</FormItem> </FormItem>

View File

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

4093
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -36,8 +36,8 @@
"dotenv-cli": "^7.3.0", "dotenv-cli": "^7.3.0",
"eslint": "^8.40.0", "eslint": "^8.40.0",
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^8.0.0", "husky": "^9.0.11",
"lint-staged": "^14.0.0", "lint-staged": "^15.2.2",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"
@ -48,6 +48,7 @@
"packages/*" "packages/*"
], ],
"dependencies": { "dependencies": {
"@documenso/pdf-sign": "^0.1.0",
"next-runtime-env": "^3.2.0" "next-runtime-env": "^3.2.0"
}, },
"overrides": { "overrides": {

View File

@ -1,6 +1,9 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import path from 'node:path'; import path from 'node:path';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { DocumentStatus } from '@documenso/prisma/client';
import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component'; import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
test(`[PR-718]: should be able to create a document`, async ({ page }) => { test(`[PR-718]: should be able to create a document`, async ({ page }) => {
@ -73,3 +76,264 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
// Assert document was created // Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible(); await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
}); });
test('should be able to create a document with multiple recipients', async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Email*').fill('user1@example.com');
await page.getByLabel('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByLabel('Email*').nth(1).fill('user2@example.com');
await page.getByLabel('Name').nth(1).fill('User 2');
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'User 1 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 100,
y: 200,
},
});
await page.getByText('User 1 (user1@example.com)').click();
await page.getByText('User 2 (user2@example.com)').click();
await page.getByRole('button', { name: 'User 2 Signature' }).click();
await page.locator('canvas').click({
position: {
x: 500,
y: 100,
},
});
await page.getByRole('button', { name: 'Email Email' }).click();
await page.locator('canvas').click({
position: {
x: 500,
y: 200,
},
});
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
});
test('should be able to create, send and sign a document', async ({ page }) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Email*').fill('user1@example.com');
await page.getByLabel('Name').fill('User 1');
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
const url = await page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
email: 'user1@example.com',
documentId: Number(documentId),
});
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed
const { status } = await getDocumentByToken({ token });
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${token}/complete`);
await expect(page.getByText('You have signed')).toBeVisible();
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken({ token });
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
});
test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
page,
}) => {
await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`;
// Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
await page.getByRole('button', { name: 'Sign In' }).click();
// Upload document
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
}),
]);
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
// Wait to be redirected to the edit page
await page.waitForURL(/\/documents\/\d+/);
// Set title
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByLabel('Email*').fill('user1@example.com');
await page.getByLabel('Name').fill('User 1');
await page.getByRole('button', { name: 'Continue' }).click();
// Add fields
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Continue' }).click();
// Add subject and send
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Send' }).click();
await page.waitForURL('/documents');
// Assert document was created
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
await page.getByRole('link', { name: documentTitle }).click();
const url = await page.url().split('/');
const documentId = url[url.length - 1];
const { token } = await getRecipientByEmail({
email: 'user1@example.com',
documentId: Number(documentId),
});
await page.goto(`/sign/${token}`);
await page.waitForURL(`/sign/${token}`);
// Check if document has been viewed
const { status } = await getDocumentByToken({ token });
expect(status).toBe(DocumentStatus.PENDING);
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL('https://documenso.com');
// Check if document has been signed
const { status: completedStatus } = await getDocumentByToken({ token });
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
});

View File

@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { seedUser } from '@documenso/prisma/seed/users';
import { manualLogin } from './fixtures/authentication';
test('update user name', async ({ page }) => {
const user = await seedUser();
await manualLogin({
page,
email: user.email,
redirectPath: '/settings/profile',
});
await page.getByLabel('Full Name').fill('John Doe');
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
await page.mouse.up();
}
await page.getByRole('button', { name: 'Update profile' }).click();
// wait for it to finish
await expect(page.getByText('Profile updated', { exact: true })).toBeVisible();
await page.waitForURL('/settings/profile');
expect((await getUserByEmail({ email: user.email })).name).toEqual('John Doe');
});

View File

@ -6,6 +6,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test:dev": "playwright test", "test:dev": "playwright test",
"test-ui:dev": "playwright test --ui",
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\"" "test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
}, },
"keywords": [], "keywords": [],

View File

@ -4,16 +4,15 @@ module.exports = {
'turbo', 'turbo',
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:package-json/recommended', 'plugin:package-json/recommended',
], ],
plugins: ['prettier', 'package-json', 'unused-imports'], plugins: ['package-json', 'unused-imports'],
env: { env: {
es2022: true,
node: true, node: true,
browser: true, browser: true,
es6: true,
}, },
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',

View File

@ -7,16 +7,14 @@
"clean": "rimraf node_modules" "clean": "rimraf node_modules"
}, },
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "6.8.0", "@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "6.8.0", "@typescript-eslint/parser": "^7.1.1",
"eslint": "^8.40.0", "eslint": "^8.57.0",
"eslint-config-next": "13.4.19", "eslint-config-next": "^14.1.3",
"eslint-config-prettier": "^8.8.0", "eslint-config-turbo": "^1.12.5",
"eslint-config-turbo": "^1.9.3", "eslint-plugin-package-json": "^0.10.4",
"eslint-plugin-package-json": "^0.2.0", "eslint-plugin-react": "^7.34.0",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-unused-imports": "^3.1.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-unused-imports": "^3.0.0",
"typescript": "5.2.2" "typescript": "5.2.2"
} }
} }

View File

@ -13,7 +13,7 @@ export const DATE_FORMATS = [
{ {
key: 'YYYYMMDD', key: 'YYYYMMDD',
label: 'YYYY-MM-DD', label: 'YYYY-MM-DD',
value: 'YYYY-MM-DD', value: 'yyyy-MM-dd',
}, },
{ {
key: 'DDMMYYYY', key: 'DDMMYYYY',

View File

@ -1,40 +1,30 @@
import { compare } from '@node-rs/bcrypt';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { UserSecurityAuditLogType } from '@documenso/prisma/client'; import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes'; import { AppError } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa'; import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = { type DisableTwoFactorAuthenticationOptions = {
user: User; user: User;
backupCode: string; token: string;
password: string;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
export const disableTwoFactorAuthentication = async ({ export const disableTwoFactorAuthentication = async ({
backupCode, token,
user, user,
password,
requestMetadata, requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => { }: DisableTwoFactorAuthenticationOptions) => {
if (!user.password) { let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
if (!isValid) { if (!isValid) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE); isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
}
if (!isValid) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {

View File

@ -1,7 +1,7 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client'; import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata'; import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getBackupCodes } from './get-backup-code'; import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
@ -17,25 +17,38 @@ export const enableTwoFactorAuthentication = async ({
code, code,
requestMetadata, requestMetadata,
}: EnableTwoFactorAuthenticationOptions) => { }: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (user.twoFactorEnabled) { if (user.twoFactorEnabled) {
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED); throw new AppError('TWO_FACTOR_ALREADY_ENABLED');
} }
if (!user.twoFactorSecret) { if (!user.twoFactorSecret) {
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED); throw new AppError('TWO_FACTOR_SETUP_REQUIRED');
} }
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code }); const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
if (!isValidToken) { if (!isValidToken) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE); throw new AppError('INCORRECT_TWO_FACTOR_CODE');
}
let recoveryCodes: string[] = [];
await prisma.$transaction(async (tx) => {
const updatedUser = await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
recoveryCodes = getBackupCodes({ user: updatedUser }) ?? [];
if (recoveryCodes.length === 0) {
throw new AppError('MISSING_BACKUP_CODE');
} }
const updatedUser = await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({ await tx.userSecurityAuditLog.create({
data: { data: {
userId: user.id, userId: user.id,
@ -44,18 +57,7 @@ export const enableTwoFactorAuthentication = async ({
ipAddress: requestMetadata?.ipAddress, ipAddress: requestMetadata?.ipAddress,
}, },
}); });
return await tx.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
}); });
});
const recoveryCodes = getBackupCodes({ user: updatedUser });
return { recoveryCodes }; return { recoveryCodes };
}; };

View File

@ -1,4 +1,3 @@
import { compare } from '@node-rs/bcrypt';
import { base32 } from '@scure/base'; import { base32 } from '@scure/base';
import crypto from 'crypto'; import crypto from 'crypto';
import { createTOTPKeyURI } from 'oslo/otp'; import { createTOTPKeyURI } from 'oslo/otp';
@ -12,14 +11,12 @@ import { symmetricEncrypt } from '../../universal/crypto';
type SetupTwoFactorAuthenticationOptions = { type SetupTwoFactorAuthenticationOptions = {
user: User; user: User;
password: string;
}; };
const ISSUER = 'Documenso'; const ISSUER = 'Documenso';
export const setupTwoFactorAuthentication = async ({ export const setupTwoFactorAuthentication = async ({
user, user,
password,
}: SetupTwoFactorAuthenticationOptions) => { }: SetupTwoFactorAuthenticationOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY; const key = DOCUMENSO_ENCRYPTION_KEY;
@ -27,20 +24,6 @@ export const setupTwoFactorAuthentication = async ({
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY); throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
} }
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const secret = crypto.randomBytes(10); const secret = crypto.randomBytes(10);
const backupCodes = Array.from({ length: 10 }) const backupCodes = Array.from({ length: 10 })

View File

@ -0,0 +1,30 @@
import type { User } from '@documenso/prisma/client';
import { AppError } from '../../errors/app-error';
import { getBackupCodes } from './get-backup-code';
import { validateTwoFactorAuthentication } from './validate-2fa';
type ViewBackupCodesOptions = {
user: User;
token: string;
};
export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => {
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
if (!isValid) {
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
}
if (!isValid) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
}
const backupCodes = getBackupCodes({ user });
if (!backupCodes) {
throw new AppError('MISSING_BACKUP_CODE');
}
return backupCodes;
};

View File

@ -30,6 +30,27 @@ export interface GetDocumentAndRecipientByTokenOptions {
*/ */
requireAccessAuth?: boolean; requireAccessAuth?: boolean;
} }
export type GetDocumentByTokenOptions = {
token: string;
};
export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => {
if (!token) {
throw new Error('Missing token');
}
const result = await prisma.document.findFirstOrThrow({
where: {
Recipient: {
some: {
token,
},
},
},
});
return result;
};
export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>; export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>;

View File

@ -0,0 +1,35 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
export type GetCompletedDocumentsMonthlyResult = Array<{
month: string;
count: number;
cume_count: number;
}>;
type GetCompletedDocumentsMonthlyQueryResult = Array<{
month: Date;
count: bigint;
cume_count: bigint;
}>;
export const getCompletedDocumentsMonthly = async () => {
const result = await prisma.$queryRaw<GetCompletedDocumentsMonthlyQueryResult>`
SELECT
DATE_TRUNC('month', "updatedAt") AS "month",
COUNT("id") as "count",
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "updatedAt")) as "cume_count"
FROM "Document"
WHERE "status" = 'COMPLETED'
GROUP BY "month"
ORDER BY "month" DESC
LIMIT 12
`;
return result.map((row) => ({
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
count: Number(row.count),
cume_count: Number(row.cume_count),
}));
};

View File

@ -182,6 +182,7 @@ const createCompletedDocument = async (sender: User, recipients: User[]) => {
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`, title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`,
status: DocumentStatus.COMPLETED, status: DocumentStatus.COMPLETED,
documentDataId: documentData.id, documentDataId: documentData.id,
completedAt: new Date(),
userId: sender.id, userId: sender.id,
}, },
}); });

View File

@ -0,0 +1,3 @@
// We use stars as a placeholder since it's easy to find and replace,
// the length of the placeholder is to support larger pdf files
export const BYTE_RANGE_PLACEHOLDER = '**********';

View File

@ -1,4 +1,3 @@
import signer from 'node-signpdf';
import { import {
PDFArray, PDFArray,
PDFDocument, PDFDocument,
@ -9,6 +8,8 @@ import {
rectangle, rectangle,
} from 'pdf-lib'; } from 'pdf-lib';
import { BYTE_RANGE_PLACEHOLDER } from '../constants/byte-range';
export type AddSigningPlaceholderOptions = { export type AddSigningPlaceholderOptions = {
pdf: Buffer; pdf: Buffer;
}; };
@ -20,9 +21,9 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
const byteRange = PDFArray.withContext(doc.context); const byteRange = PDFArray.withContext(doc.context);
byteRange.push(PDFNumber.of(0)); byteRange.push(PDFNumber.of(0));
byteRange.push(PDFName.of(signer.byteRangePlaceholder)); byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(signer.byteRangePlaceholder)); byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
byteRange.push(PDFName.of(signer.byteRangePlaceholder)); byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
const signature = doc.context.obj({ const signature = doc.context.obj({
Type: 'Sig', Type: 'Sig',

View File

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { updateSigningPlaceholder } from './update-signing-placeholder';
describe('updateSigningPlaceholder', () => {
const pdf = Buffer.from(`
20 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
`);
it('should not throw an error', () => {
expect(() => updateSigningPlaceholder({ pdf })).not.toThrowError();
});
it('should not modify the original PDF', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.pdf).not.toEqual(pdf);
});
it('should return a PDF with the same length as the original', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.pdf).toHaveLength(pdf.length);
});
it('should update the byte range and return it', () => {
const result = updateSigningPlaceholder({ pdf });
expect(result.byteRange).toEqual([0, 184, 241, 92]);
});
it('should only update the last signature in the PDF', () => {
const pdf = Buffer.from(`
20 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
21 0 obj
<<
/Type /Sig
/Filter /Adobe.PPKLite
/SubFilter /adbe.pkcs7.detached
/ByteRange [ 0 /********** /********** /********** ]
/Contents <0000000000000000000000000000000000000000000000000000000>
/Reason (Signed by Documenso)
/M (D:20210101000000Z)
>>
endobj
`);
const result = updateSigningPlaceholder({ pdf });
expect(result.byteRange).toEqual([0, 512, 569, 92]);
});
});

View File

@ -0,0 +1,39 @@
export type UpdateSigningPlaceholderOptions = {
pdf: Buffer;
};
export const updateSigningPlaceholder = ({ pdf }: UpdateSigningPlaceholderOptions) => {
const length = pdf.length;
const byteRangePos = pdf.lastIndexOf('/ByteRange');
const byteRangeStart = pdf.indexOf('[', byteRangePos);
const byteRangeEnd = pdf.indexOf(']', byteRangePos);
const byteRangeSlice = pdf.subarray(byteRangeStart, byteRangeEnd + 1);
const signaturePos = pdf.indexOf('/Contents', byteRangeEnd);
const signatureStart = pdf.indexOf('<', signaturePos);
const signatureEnd = pdf.indexOf('>', signaturePos);
const signatureSlice = pdf.subarray(signatureStart, signatureEnd + 1);
const byteRange = [0, 0, 0, 0];
byteRange[1] = signatureStart;
byteRange[2] = byteRange[1] + signatureSlice.length;
byteRange[3] = length - byteRange[2];
const newByteRange = `[${byteRange.join(' ')}]`.padEnd(byteRangeSlice.length, ' ');
const updatedPdf = Buffer.concat([
pdf.subarray(0, byteRangeStart),
Buffer.from(newByteRange),
pdf.subarray(byteRangeEnd + 1),
]);
if (updatedPdf.length !== length) {
throw new Error('Updated PDF length does not match original length');
}
return { pdf: updatedPdf, byteRange };
};

View File

@ -1,5 +1,6 @@
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { signWithGoogleCloudHSM } from './transports/google-cloud-hsm';
import { signWithLocalCert } from './transports/local-cert'; import { signWithLocalCert } from './transports/local-cert';
export type SignOptions = { export type SignOptions = {
@ -11,6 +12,7 @@ export const signPdf = async ({ pdf }: SignOptions) => {
return await match(transport) return await match(transport)
.with('local', async () => signWithLocalCert({ pdf })) .with('local', async () => signWithLocalCert({ pdf }))
.with('gcloud-hsm', async () => signWithGoogleCloudHSM({ pdf }))
.otherwise(() => { .otherwise(() => {
throw new Error(`Unsupported signing transport: ${transport}`); throw new Error(`Unsupported signing transport: ${transport}`);
}); });

View File

@ -9,15 +9,15 @@
"index.ts" "index.ts"
], ],
"scripts": { "scripts": {
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"@documenso/tsconfig": "*", "@documenso/tsconfig": "*",
"node-forge": "^1.3.1", "@documenso/pdf-sign": "^0.1.0",
"node-signpdf": "^2.0.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"ts-pattern": "^5.0.5" "ts-pattern": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node-forge": "^1.3.4" "vitest": "^1.3.1"
} }
} }

View File

@ -0,0 +1,79 @@
import fs from 'node:fs';
import { signWithGCloud } from '@documenso/pdf-sign';
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
export type SignWithGoogleCloudHSMOptions = {
pdf: Buffer;
};
export const signWithGoogleCloudHSM = async ({ pdf }: SignWithGoogleCloudHSMOptions) => {
const keyPath = process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH;
if (!keyPath) {
throw new Error('No certificate path provided for Google Cloud HSM signing');
}
// To handle hosting in serverless environments like Vercel we can supply the base64 encoded
// application credentials as an environment variable and write it to a file if it doesn't exist
if (
process.env.GOOGLE_APPLICATION_CREDENTIALS &&
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS
) {
if (!fs.existsSync(process.env.GOOGLE_APPLICATION_CREDENTIALS)) {
fs.writeFileSync(
process.env.GOOGLE_APPLICATION_CREDENTIALS,
Buffer.from(
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS,
'base64',
),
);
}
}
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
pdf: await addSigningPlaceholder({ pdf }),
});
const pdfWithoutSignature = Buffer.concat([
pdfWithPlaceholder.subarray(0, byteRange[1]),
pdfWithPlaceholder.subarray(byteRange[2]),
]);
const signatureLength = byteRange[2] - byteRange[1];
let cert: Buffer | null = null;
if (process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS) {
cert = Buffer.from(
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS,
'base64',
);
}
if (!cert) {
cert = Buffer.from(
fs.readFileSync(
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH || './example/cert.crt',
),
);
}
const signature = signWithGCloud({
keyPath,
cert,
content: pdfWithoutSignature,
});
const signatureAsHex = signature.toString('hex');
const signedPdf = Buffer.concat([
pdfWithPlaceholder.subarray(0, byteRange[1]),
Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`),
pdfWithPlaceholder.subarray(byteRange[2]),
]);
return signedPdf;
};

View File

@ -1,32 +1,51 @@
import signer from 'node-signpdf';
import fs from 'node:fs'; import fs from 'node:fs';
import { addSigningPlaceholder } from '../helpers/addSigningPlaceholder'; import { signWithP12 } from '@documenso/pdf-sign';
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
export type SignWithLocalCertOptions = { export type SignWithLocalCertOptions = {
pdf: Buffer; pdf: Buffer;
}; };
export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => { export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
const pdfWithPlaceholder = await addSigningPlaceholder({ pdf }); const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
pdf: await addSigningPlaceholder({ pdf }),
});
let p12Cert: Buffer | null = null; const pdfWithoutSignature = Buffer.concat([
pdfWithPlaceholder.subarray(0, byteRange[1]),
pdfWithPlaceholder.subarray(byteRange[2]),
]);
const signatureLength = byteRange[2] - byteRange[1];
let cert: Buffer | null = null;
if (process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS) { if (process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS) {
p12Cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64'); cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64');
} }
if (!p12Cert) { if (!cert) {
p12Cert = Buffer.from( cert = Buffer.from(
fs.readFileSync(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH || './example/cert.p12'), fs.readFileSync(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH || './example/cert.p12'),
); );
} }
if (process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE) { const signature = signWithP12({
return signer.sign(pdfWithPlaceholder, p12Cert, { cert,
passphrase: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE, content: pdfWithoutSignature,
password: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE || undefined,
}); });
}
return signer.sign(pdfWithPlaceholder, p12Cert); const signatureAsHex = signature.toString('hex');
const signedPdf = Buffer.concat([
pdfWithPlaceholder.subarray(0, byteRange[1]),
Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`),
pdfWithPlaceholder.subarray(byteRange[2]),
]);
return signedPdf;
}; };

View File

@ -1,33 +1,33 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { AppError } from '@documenso/lib/errors/app-error';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa'; import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa'; import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { compareSync } from '@documenso/lib/server-only/auth/hash'; import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, router } from '../trpc'; import { authenticatedProcedure, router } from '../trpc';
import { import {
ZDisableTwoFactorAuthenticationMutationSchema, ZDisableTwoFactorAuthenticationMutationSchema,
ZEnableTwoFactorAuthenticationMutationSchema, ZEnableTwoFactorAuthenticationMutationSchema,
ZSetupTwoFactorAuthenticationMutationSchema,
ZViewRecoveryCodesMutationSchema, ZViewRecoveryCodesMutationSchema,
} from './schema'; } from './schema';
export const twoFactorAuthenticationRouter = router({ export const twoFactorAuthenticationRouter = router({
setup: authenticatedProcedure setup: authenticatedProcedure.mutation(async ({ ctx }) => {
.input(ZSetupTwoFactorAuthenticationMutationSchema) try {
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
const { password } = input;
return await setupTwoFactorAuthentication({ return await setupTwoFactorAuthentication({
user, user: ctx.user,
password,
}); });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to setup two-factor authentication. Please try again later.',
});
}
}), }),
enable: authenticatedProcedure enable: authenticatedProcedure
@ -44,7 +44,11 @@ export const twoFactorAuthenticationRouter = router({
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
const error = AppError.parseError(err);
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
console.error(err); console.error(err);
}
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@ -59,16 +63,17 @@ export const twoFactorAuthenticationRouter = router({
try { try {
const user = ctx.user; const user = ctx.user;
const { password, backupCode } = input;
return await disableTwoFactorAuthentication({ return await disableTwoFactorAuthentication({
user, user,
password, token: input.token,
backupCode,
requestMetadata: extractNextApiRequestMetadata(ctx.req), requestMetadata: extractNextApiRequestMetadata(ctx.req),
}); });
} catch (err) { } catch (err) {
const error = AppError.parseError(err);
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
console.error(err); console.error(err);
}
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
@ -81,38 +86,18 @@ export const twoFactorAuthenticationRouter = router({
.input(ZViewRecoveryCodesMutationSchema) .input(ZViewRecoveryCodesMutationSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
const user = ctx.user; return await viewBackupCodes({
user: ctx.user,
const { password } = input; token: input.token,
if (!user.twoFactorEnabled) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: ErrorCode.TWO_FACTOR_SETUP_REQUIRED,
}); });
}
if (!user.password || !compareSync(password, user.password)) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: ErrorCode.INCORRECT_PASSWORD,
});
}
const recoveryCodes = await getBackupCodes({ user });
return { recoveryCodes };
} catch (err) { } catch (err) {
console.error(err); const error = AppError.parseError(err);
if (err instanceof TRPCError) { if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
throw err; console.error(err);
} }
throw new TRPCError({ throw AppError.parseErrorToTRPCError(err);
code: 'BAD_REQUEST',
message: 'We were unable to view your recovery codes. Please try again later.',
});
} }
}), }),
}); });

View File

@ -1,13 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export const ZSetupTwoFactorAuthenticationMutationSchema = z.object({
password: z.string().min(1),
});
export type TSetupTwoFactorAuthenticationMutationSchema = z.infer<
typeof ZSetupTwoFactorAuthenticationMutationSchema
>;
export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({ export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({
code: z.string().min(6).max(6), code: z.string().min(6).max(6),
}); });
@ -17,8 +9,7 @@ export type TEnableTwoFactorAuthenticationMutationSchema = z.infer<
>; >;
export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({ export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({
password: z.string().min(6).max(72), token: z.string().trim().min(1),
backupCode: z.string().trim(),
}); });
export type TDisableTwoFactorAuthenticationMutationSchema = z.infer< export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
@ -26,7 +17,7 @@ export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
>; >;
export const ZViewRecoveryCodesMutationSchema = z.object({ export const ZViewRecoveryCodesMutationSchema = z.object({
password: z.string().min(6).max(72), token: z.string().trim().min(1),
}); });
export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>; export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>;

View File

@ -30,6 +30,10 @@ declare namespace NodeJS {
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?: string; NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS?: string; NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING?: string; NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS?: string;
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS?: string;
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api'; NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api';

View File

@ -178,9 +178,10 @@ export const AddSignersFormPartial = ({
<Form {...form}> <Form {...form}>
<div className="flex w-full flex-col gap-y-2"> <div className="flex w-full flex-col gap-y-2">
{signers.map((signer, index) => ( {signers.map((signer, index) => (
<motion.div <motion.fieldset
key={signer.id} key={signer.id}
data-native-id={signer.nativeId} data-native-id={signer.nativeId}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
className={cn('grid grid-cols-8 gap-4 pb-4', { className={cn('grid grid-cols-8 gap-4 pb-4', {
'border-b pt-2': showAdvancedSettings, 'border-b pt-2': showAdvancedSettings,
})} })}
@ -312,7 +313,11 @@ export const AddSignersFormPartial = ({
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-1 mt-auto"> <FormItem className="col-span-1 mt-auto">
<FormControl> <FormControl>
<Select {...field} onValueChange={field.onChange}> <Select
{...field}
onValueChange={field.onChange}
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
>
<SelectTrigger className="bg-background w-[60px]"> <SelectTrigger className="bg-background w-[60px]">
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */} {/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
{ROLE_ICONS[field.value as RecipientRole]} {ROLE_ICONS[field.value as RecipientRole]}
@ -367,7 +372,7 @@ export const AddSignersFormPartial = ({
> >
<Trash className="h-5 w-5" /> <Trash className="h-5 w-5" />
</button> </button>
</motion.div> </motion.fieldset>
))} ))}
</div> </div>

View File

@ -48,6 +48,14 @@
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL",
"NEXT_PRIVATE_SIGNING_TRANSPORT",
"NEXT_PRIVATE_SIGNING_PASSPHRASE",
"NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH",
"NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH",
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS",
"NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID", "NEXT_PRIVATE_GOOGLE_CLIENT_ID",
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET", "NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
"NEXT_PUBLIC_UPLOAD_TRANSPORT", "NEXT_PUBLIC_UPLOAD_TRANSPORT",
@ -93,6 +101,7 @@
"DATABASE_URL_UNPOOLED", "DATABASE_URL_UNPOOLED",
"POSTGRES_PRISMA_URL", "POSTGRES_PRISMA_URL",
"POSTGRES_URL_NON_POOLING", "POSTGRES_URL_NON_POOLING",
"GOOGLE_APPLICATION_CREDENTIALS",
"E2E_TEST_AUTHENTICATE_USERNAME", "E2E_TEST_AUTHENTICATE_USERNAME",
"E2E_TEST_AUTHENTICATE_USER_EMAIL", "E2E_TEST_AUTHENTICATE_USER_EMAIL",
"E2E_TEST_AUTHENTICATE_USER_PASSWORD" "E2E_TEST_AUTHENTICATE_USER_PASSWORD"