Compare commits

..

17 Commits

Author SHA1 Message Date
David Nguyen
55e1c1afd0 feat: add 2FA document auth 2024-03-24 16:34:00 +08:00
David Nguyen
fd881572f8 fix: polish 2024-03-19 15:28:33 +08:00
David Nguyen
3282481ad7 fix: add no passkey flow 2024-03-17 23:12:25 +08:00
David Nguyen
1ed18059fb feat: initial reauth passkeys 2024-03-17 20:33:11 +08:00
David Nguyen
d45bed6930 Merge branch 'feat/passkey' into feat/document-passkey-test 2024-03-17 15:14:32 +08:00
David Nguyen
87b79451d5 fix: add passkey limits 2024-03-17 15:10:32 +08:00
David Nguyen
e4ad940a06 chore: typo 2024-03-17 14:46:33 +08:00
David Nguyen
cb020cc7d0 fix: squish passkeys 2024-03-17 14:20:42 +08:00
David Nguyen
5033799724 fix: squish 2024-03-17 14:17:49 +08:00
David Nguyen
b22de4bd71 fix: refactor 2024-03-15 17:08:15 +08:00
David Nguyen
aa926d6642 fix: disable form 2024-03-15 16:53:58 +08:00
David Nguyen
a802f0bceb fix: add passkey instruction 2024-03-15 16:42:32 +08:00
David Nguyen
034318e571 fix: add passkey loading 2024-03-15 16:28:08 +08:00
David Nguyen
75319f20cb Merge branch 'main' into feat/passkey 2024-03-15 14:18:57 +08:00
David Nguyen
b348e3c952 Merge branch 'main' into feat/passkey 2024-03-07 18:27:23 +08:00
David Nguyen
280a258529 Merge branch 'main' into feat/passkey 2024-03-06 15:13:14 +08:00
David Nguyen
8d7541aa7a feat: add passkeys 2024-03-06 15:07:23 +08:00
108 changed files with 1743 additions and 5897 deletions

View File

@@ -1,10 +1,13 @@
#!/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 dev setup # Run the migrations
npm run dx npm run prisma:migrate-dev

View File

@@ -22,23 +22,10 @@ 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"
# [[SIGNING]] # [[E2E Tests]]
# The transport to use for document signing. Available options: local (default) | gcloud-hsm E2E_TEST_AUTHENTICATE_USERNAME="Test User"
NEXT_PRIVATE_SIGNING_TRANSPORT="local" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
# OPTIONAL: The passphrase to use for the local file-based signing transport. E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
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)
@@ -55,9 +42,6 @@ NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
NEXT_PUBLIC_UPLOAD_TRANSPORT="database" NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers. # OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002" NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
# OPTIONAL: Defines the force path style to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
# This will change it from using virtual hosts <bucket>.domain.com/<path> to fully qualified paths domain.com/<bucket>/<path>
NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE="false"
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1. # OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
NEXT_PRIVATE_UPLOAD_REGION="unknown" NEXT_PRIVATE_UPLOAD_REGION="unknown"
# REQUIRED: Defines the bucket to use for the S3 storage transport. # REQUIRED: Defines the bucket to use for the S3 storage transport.
@@ -107,7 +91,6 @@ NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
# [[FEATURES]] # [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags. # OPTIONAL: Leave blank to disable PostHog and feature flags.
@@ -117,11 +100,6 @@ 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,9 +1,7 @@
{ {
"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": "explicit" "source.fixAll.eslint": "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', '@documenso/pdf-sign'], serverComponentsExternalPackages: ['@node-rs/bcrypt'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },

View File

@@ -38,8 +38,7 @@ 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': 'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
pathname && !['/singleplayer', '/pricing'].includes(pathname),
})} })}
> >
<div <div

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import type { HTMLAttributes } from 'react'; import { 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;
@@ -33,13 +34,13 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
.reverse(); .reverse();
return ( return (
<div className={className} {...props}> <div className={cn('flex flex-col', className)} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow"> <div className="flex items-center px-4">
<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,6 +5,8 @@ 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'];
@@ -47,12 +49,10 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
setIsSSR(false); setIsSSR(false);
}, []); }, []);
return ( return (
<div className={className} {...props}> <div className={cn('flex flex-col', className)} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow"> <h3 className="px-4 text-lg font-semibold">Cap Table</h3>
<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,10 +1,11 @@
'use client'; 'use client';
import type { HTMLAttributes } from 'react'; import { 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>[];
@@ -17,12 +18,10 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
})); }));
return ( return (
<div className={className} {...props}> <div className={cn('flex flex-col', className)} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow"> <h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
<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

@@ -1,56 +0,0 @@
'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,6 +4,7 @@ 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;
@@ -19,12 +20,12 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
}); });
return ( return (
<div className={className}> <div className={cn('flex flex-col', className)}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow"> <div className="flex items-center px-4">
<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,6 +4,7 @@ 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;
@@ -19,12 +20,12 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
}); });
return ( return (
<div className={className}> <div className={cn('flex flex-col', className)}>
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow"> <div className="flex items-center px-4">
<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,23 +2,20 @@ 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 = {
@@ -134,18 +131,16 @@ 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">
@@ -166,7 +161,7 @@ export default async function OpenPage() {
</p> </p>
</div> </div>
<div className="my-12 grid grid-cols-12 gap-8"> <div className="mt-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"
@@ -193,57 +188,11 @@ 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"
@@ -253,29 +202,57 @@ 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" />
<MonthlyCompletedDocumentsChart <Typefully className="col-span-12 lg:col-span-6" />
data={MONTHLY_COMPLETED_DOCUMENTS}
className="col-span-12 lg:col-span-6" <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>
<TotalSignedDocumentsChart
data={MONTHLY_COMPLETED_DOCUMENTS} <p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
className="col-span-12 lg:col-span-6" We're still working on getting all our metrics together. We'll update this page as
/> soon as we have more to share.
</p>
</div>
</div> </div>
</div> </div>
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
<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">
This page is evolving as we learn what makes a great signing company. We'll update it when
we have more to share.
</p>
</div>
<CallToAction className="mt-12" utmSource="open-page" /> <CallToAction className="mt-12" utmSource="open-page" />
</div> </div>
); );

View File

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

View File

@@ -1,56 +0,0 @@
'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,19 +6,18 @@ 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={className} {...props}> <div className={cn('flex flex-col', className)} {...props}>
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow"> <h3 className="px-4 text-lg font-semibold">Twitter Stats</h3>
<div className="mb-6 flex px-4">
<h3 className="text-lg font-semibold">Twitter Stats</h3>
</div>
<div className="my-12 flex flex-col items-center gap-y-4 text-center"> <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="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

@@ -247,7 +247,6 @@ export const SinglePlayerClient = () => {
recipients={uploadedFile ? [placeholderRecipient] : []} recipients={uploadedFile ? [placeholderRecipient] : []}
fields={fields} fields={fields}
onSubmit={onFieldsSubmit} onSubmit={onFieldsSubmit}
isDocumentPdfLoaded={true}
/> />
</fieldset> </fieldset>

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="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"> <div className="flex items-center justify-center gap-x-6">
<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-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full" className="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-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full" className="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', '@documenso/pdf-sign'], serverComponentsExternalPackages: ['@node-rs/bcrypt'],
serverActions: { serverActions: {
bodySizeLimit: '50mb', bodySizeLimit: '50mb',
}, },

View File

@@ -25,7 +25,6 @@
"@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.3", "@simplewebauthn/server": "^9.0.3",
"@tanstack/react-query": "^4.29.5", "@tanstack/react-query": "^4.29.5",
"cookie-es": "^1.0.0",
"formidable": "^2.1.1", "formidable": "^2.1.1",
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.279.0", "lucide-react": "^0.279.0",
@@ -71,4 +70,4 @@
"next": "$next" "next": "$next"
} }
} }
} }

View File

@@ -1,14 +1,17 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION, type DocumentData,
SKIP_QUERY_BATCH_META, type DocumentMeta,
} from '@documenso/lib/constants/trpc'; type Field,
import type { DocumentWithDetails } from '@documenso/prisma/types/document'; type Recipient,
type User,
} from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -30,9 +33,13 @@ import { useOptionalCurrentTeam } from '~/providers/team';
export type EditDocumentFormProps = { export type EditDocumentFormProps = {
className?: string; className?: string;
initialDocument: DocumentWithDetails; user: User;
document: DocumentWithData;
recipients: Recipient[];
documentMeta: DocumentMeta | null;
fields: Field[];
documentData: DocumentData;
documentRootPath: string; documentRootPath: string;
isDocumentEnterprise: boolean;
}; };
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject'; type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
@@ -40,9 +47,13 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
export const EditDocumentForm = ({ export const EditDocumentForm = ({
className, className,
initialDocument, document,
recipients,
fields,
documentMeta,
user: _user,
documentData,
documentRootPath, documentRootPath,
isDocumentEnterprise,
}: EditDocumentFormProps) => { }: EditDocumentFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
@@ -50,76 +61,11 @@ export const EditDocumentForm = ({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const team = useOptionalCurrentTeam(); const team = useOptionalCurrentTeam();
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false); const { mutateAsync: setSettingsForDocument } =
trpc.document.setSettingsForDocument.useMutation();
const utils = trpc.useUtils(); const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
const { data: document, refetch: refetchDocument } = const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
trpc.document.getDocumentWithDetailsById.useQuery(
{
id: initialDocument.id,
teamId: team?.id,
},
{
initialData: initialDocument,
...SKIP_QUERY_BATCH_META,
},
);
const { Recipient: recipients, Field: fields } = document;
const { mutateAsync: setSettingsForDocument } = trpc.document.setSettingsForDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
},
});
const { mutateAsync: addFields } = trpc.field.addFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newFields) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), Field: newFields }),
);
},
});
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newRecipients) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), Recipient: newRecipients }),
);
},
});
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.document.getDocumentWithDetailsById.setData(
{
id: initialDocument.id,
teamId: team?.id,
},
(oldData) => ({ ...(oldData || initialDocument), ...newData }),
);
},
});
const { mutateAsync: setPasswordForDocument } = const { mutateAsync: setPasswordForDocument } =
trpc.document.setPasswordForDocument.useMutation(); trpc.document.setPasswordForDocument.useMutation();
@@ -182,7 +128,6 @@ export const EditDocumentForm = ({
}, },
}); });
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh(); router.refresh();
setStep('signers'); setStep('signers');
@@ -191,7 +136,7 @@ export const EditDocumentForm = ({
toast({ toast({
title: 'Error', title: 'Error',
description: 'An error occurred while updating the document settings.', description: 'An error occurred while updating the general settings.',
variant: 'destructive', variant: 'destructive',
}); });
} }
@@ -199,6 +144,7 @@ export const EditDocumentForm = ({
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try { try {
// Custom invocation server action
await addSigners({ await addSigners({
documentId: document.id, documentId: document.id,
teamId: team?.id, teamId: team?.id,
@@ -209,9 +155,7 @@ export const EditDocumentForm = ({
})), })),
}); });
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh(); router.refresh();
setStep('fields'); setStep('fields');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -226,14 +170,13 @@ export const EditDocumentForm = ({
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => { const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
try { try {
// Custom invocation server action
await addFields({ await addFields({
documentId: document.id, documentId: document.id,
fields: data.fields, fields: data.fields,
}); });
// Router refresh is here to clear the router cache for when navigating to /documents.
router.refresh(); router.refresh();
setStep('subject'); setStep('subject');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -286,15 +229,6 @@ export const EditDocumentForm = ({
const currentDocumentFlow = documentFlow[step]; const currentDocumentFlow = documentFlow[step];
/**
* Refresh the data in the background when steps change.
*/
useEffect(() => {
void refetchDocument();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return ( return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}> <div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card <Card
@@ -303,12 +237,11 @@ export const EditDocumentForm = ({
> >
<CardContent className="p-2"> <CardContent className="p-2">
<LazyPDFViewer <LazyPDFViewer
key={document.documentData.id} key={documentData.id}
documentData={document.documentData} documentData={documentData}
document={document} document={document}
password={document.documentMeta?.password} password={documentMeta?.password}
onPasswordSubmit={onPasswordSubmit} onPasswordSubmit={onPasswordSubmit}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -328,18 +261,15 @@ export const EditDocumentForm = ({
document={document} document={document}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
onSubmit={onAddSettingsFormSubmit} onSubmit={onAddSettingsFormSubmit}
/> />
<AddSignersFormPartial <AddSignersFormPartial
key={recipients.length} key={recipients.length}
documentFlow={documentFlow.signers} documentFlow={documentFlow.signers}
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddFieldsFormPartial <AddFieldsFormPartial
@@ -348,7 +278,6 @@ export const EditDocumentForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddFieldsFormSubmit} onSubmit={onAddFieldsFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
<AddSubjectFormPartial <AddSubjectFormPartial
@@ -358,7 +287,6 @@ export const EditDocumentForm = ({
recipients={recipients} recipients={recipients}
fields={fields} fields={fields}
onSubmit={onAddSubjectFormSubmit} onSubmit={onAddSubjectFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/> />
</Stepper> </Stepper>
</DocumentFlowFormContainer> </DocumentFlowFormContainer>

View File

@@ -3,10 +3,11 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react'; import { ChevronLeft, Users2 } from 'lucide-react';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { Team } from '@documenso/prisma/client'; import type { Team } from '@documenso/prisma/client';
@@ -36,18 +37,13 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
const { user } = await getRequiredServerComponentSession(); const { user } = await getRequiredServerComponentSession();
const isDocumentEnterprise = await isUserEnterprise({ const document = await getDocumentById({
userId: user.id,
teamId: team?.id,
});
const document = await getDocumentWithDetailsById({
id: documentId, id: documentId,
userId: user.id, userId: user.id,
teamId: team?.id, teamId: team?.id,
}).catch(() => null); }).catch(() => null);
if (!document) { if (!document || !document.documentData) {
redirect(documentRootPath); redirect(documentRootPath);
} }
@@ -55,7 +51,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
redirect(`${documentRootPath}/${documentId}`); redirect(`${documentRootPath}/${documentId}`);
} }
const { documentMeta, Recipient: recipients } = document; const { documentData, documentMeta } = document;
if (documentMeta?.password) { if (documentMeta?.password) {
const key = DOCUMENSO_ENCRYPTION_KEY; const key = DOCUMENSO_ENCRYPTION_KEY;
@@ -74,6 +70,18 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
documentMeta.password = securePassword; documentMeta.password = securePassword;
} }
const [recipients, fields] = await Promise.all([
getRecipientsForDocument({
documentId,
userId: user.id,
teamId: team?.id,
}),
getFieldsForDocument({
documentId,
userId: user.id,
}),
]);
return ( return (
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8"> <div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
<Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80"> <Link href={documentRootPath} className="flex items-center text-[#7AC455] hover:opacity-80">
@@ -101,9 +109,13 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
<EditDocumentForm <EditDocumentForm
className="mt-8" className="mt-8"
initialDocument={document} document={document}
user={user}
documentMeta={documentMeta}
recipients={recipients}
fields={fields}
documentData={documentData}
documentRootPath={documentRootPath} documentRootPath={documentRootPath}
isDocumentEnterprise={isDocumentEnterprise}
/> />
</div> </div>
); );

View File

@@ -1,15 +1,15 @@
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 { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
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 { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog'; import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
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 = {
@@ -28,70 +28,76 @@ 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"
variant="neutral" variant="neutral"
> >
<div className="mb-4 sm:mb-0"> <div className="mb-4 sm:mb-0">
<AlertTitle>Two factor authentication</AlertTitle> <AlertTitle>Two factor authentication</AlertTitle>
<AlertDescription className="mr-4"> <AlertDescription className="mr-4">
Add an authenticator to serve as a secondary authentication method{' '} Create one-time passwords that serve as a secondary authentication method for
{user.identityProvider === 'DOCUMENSO' confirming your identity when requested during the sign-in process.
? 'when signing in, or when signing documents.' </AlertDescription>
: 'for signing documents.'} </div>
</AlertDescription>
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
</Alert>
{isPasskeyEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Passkeys</AlertTitle>
<AlertDescription className="mr-4">
Allows authenticating using biometrics, password managers, hardware keys, etc.
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link href="/settings/security/passkeys">Manage passkeys</Link>
</Button>
</Alert>
)}
{user.twoFactorEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the
event that you lose access to your authenticator app.
</AlertDescription>
</div>
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
</Alert>
)}
</div> </div>
) : (
<Alert className="p-6" variant="neutral">
<AlertTitle>
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
</AlertTitle>
{user.twoFactorEnabled ? ( <AlertDescription>
<DisableAuthenticatorAppDialog /> To update your password, enable two-factor authentication, and manage other security
) : ( settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
<EnableAuthenticatorAppDialog /> settings.
)} </AlertDescription>
</Alert>
{user.twoFactorEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Recovery codes</AlertTitle>
<AlertDescription className="mr-4">
Two factor authentication recovery codes are used to access your account in the event
that you lose access to your authenticator app.
</AlertDescription>
</div>
<ViewRecoveryCodesDialog />
</Alert>
)}
{isPasskeyEnabled && (
<Alert
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<div className="mb-4 sm:mb-0">
<AlertTitle>Passkeys</AlertTitle>
<AlertDescription className="mr-4">
Allows authenticating using biometrics, password managers, hardware keys, etc.
</AlertDescription>
</div>
<Button asChild variant="outline" className="bg-background">
<Link href="/settings/security/passkeys">Manage passkeys</Link>
</Button>
</Alert> </Alert>
)} )}

View File

@@ -172,27 +172,29 @@ export const UserPasskeysDataTableActions = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<fieldset disabled={isDeletingPasskey}> <form
<DialogFooter> onSubmit={(e) => {
<DialogClose asChild> e.preventDefault();
<Button type="button" variant="secondary">
Cancel
</Button>
</DialogClose>
<Button void deletePasskey({
onClick={async () => passkeyId,
deletePasskey({ });
passkeyId, }}
}) >
} <fieldset className="flex h-full flex-col space-y-4" disabled={isDeletingPasskey}>
variant="destructive" <DialogFooter>
loading={isDeletingPasskey} <DialogClose asChild>
> <Button type="button" variant="secondary">
Delete Cancel
</Button> </Button>
</DialogFooter> </DialogClose>
</fieldset>
<Button type="submit" variant="destructive" loading={isDeletingPasskey}>
Delete
</Button>
</DialogFooter>
</fieldset>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>

View File

@@ -11,7 +11,6 @@ import {
convertToLocalSystemFormat, convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats'; } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
@@ -41,12 +40,12 @@ export const DateField = ({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation();
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;

View File

@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link';
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 { z } from 'zod'; import { z } from 'zod';
@@ -20,8 +22,6 @@ import {
} 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 { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuth2FAProps = { export type DocumentActionAuth2FAProps = {
@@ -58,7 +58,6 @@ export const DocumentActionAuth2FA = ({
}, },
}); });
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
const [formErrorCode, setFormErrorCode] = useState<string | null>(null); const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
@@ -66,7 +65,7 @@ export const DocumentActionAuth2FA = ({
setIsCurrentlyAuthenticating(true); setIsCurrentlyAuthenticating(true);
await onReauthFormSubmit({ await onReauthFormSubmit({
type: DocumentAuth.TWO_FACTOR_AUTH, type: DocumentAuth['2FA'],
token, token,
}); });
@@ -88,29 +87,17 @@ export const DocumentActionAuth2FA = ({
token: '', token: '',
}); });
setIs2FASetupSuccessful(false);
setFormErrorCode(null); setFormErrorCode(null);
}, [open, form]);
// eslint-disable-next-line react-hooks/exhaustive-deps if (!user?.twoFactorEnabled) {
}, [open]);
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Alert variant="warning"> <Alert variant="warning">
<AlertDescription> <AlertDescription>
<p> {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? 'You need to setup 2FA to mark this document as viewed.'
? 'You need to setup 2FA to mark this document as viewed.' : `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
: `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
</p>
{user?.identityProvider === 'DOCUMENSO' && (
<p className="mt-2">
By enabling 2FA, you will be required to enter a code from your authenticator app
every time you sign in.
</p>
)}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -119,7 +106,9 @@ export const DocumentActionAuth2FA = ({
Close Close
</Button> </Button>
<EnableAuthenticatorAppDialog onSuccess={() => setIs2FASetupSuccessful(true)} /> <Button type="button" asChild>
<Link href="/settings/security">Setup 2FA</Link>
</Button>
</DialogFooter> </DialogFooter>
</div> </div>
); );

View File

@@ -49,7 +49,7 @@ export const DocumentActionAuthAccount = ({
return ( return (
<fieldset disabled={isSigningOut} className="space-y-4"> <fieldset disabled={isSigningOut} className="space-y-4">
<Alert variant="warning"> <Alert>
<AlertDescription> <AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? ( {actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span> <span>
@@ -70,7 +70,11 @@ export const DocumentActionAuthAccount = ({
Cancel Cancel
</Button> </Button>
<Button onClick={async () => handleChangeAccount(recipient.email)} loading={isSigningOut}> <Button
type="submit"
onClick={async () => handleChangeAccount(recipient.email)}
loading={isSigningOut}
>
Login Login
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -1,11 +1,13 @@
import { useMemo } from 'react';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { import {
DocumentAuth, DocumentAuth,
type TRecipientActionAuth, type TRecipientActionAuth,
type TRecipientActionAuthTypes, type TRecipientActionAuthTypes,
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import type { FieldType } from '@documenso/prisma/client';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -23,7 +25,7 @@ export type DocumentActionAuthDialogProps = {
title?: string; title?: string;
documentAuthType: TRecipientActionAuthTypes; documentAuthType: TRecipientActionAuthTypes;
description?: string; description?: string;
actionTarget: FieldType | 'DOCUMENT'; actionTarget?: 'FIELD' | 'DOCUMENT';
open: boolean; open: boolean;
onOpenChange: (value: boolean) => void; onOpenChange: (value: boolean) => void;
@@ -37,6 +39,7 @@ export const DocumentActionAuthDialog = ({
title, title,
description, description,
documentAuthType, documentAuthType,
actionTarget = 'FIELD',
open, open,
onOpenChange, onOpenChange,
onReauthFormSubmit, onReauthFormSubmit,
@@ -51,32 +54,59 @@ export const DocumentActionAuthDialog = ({
onOpenChange(value); onOpenChange(value);
}; };
const actionVerb =
actionTarget === 'DOCUMENT' ? RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb : 'Sign';
const defaultTitleDescription = useMemo(() => {
if (recipient.role === 'VIEWER' && actionTarget === 'DOCUMENT') {
return {
title: 'Mark document as viewed',
description: 'Reauthentication is required to mark this document as viewed.',
};
}
return {
title: `${actionVerb} ${actionTarget.toLowerCase()}`,
description: `Reauthentication is required to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}`,
};
}, [recipient.role, actionVerb, actionTarget]);
return ( return (
<Dialog open={open} onOpenChange={handleOnOpenChange}> <Dialog open={open} onOpenChange={handleOnOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{title || 'Sign field'}</DialogTitle> <DialogTitle>{title || defaultTitleDescription.title}</DialogTitle>
<DialogDescription> <DialogDescription>
{description || 'Reauthentication is required to sign this field'} {description || defaultTitleDescription.description}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{match({ documentAuthType, user }) {match({ documentAuthType, user })
.with( .with(
{ documentAuthType: DocumentAuth.ACCOUNT }, { documentAuthType: DocumentAuth.ACCOUNT },
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in. { user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auths requires them to be logged in.
() => <DocumentActionAuthAccount onOpenChange={onOpenChange} />, () => (
<DocumentActionAuthAccount
actionVerb={actionVerb}
actionTarget={actionTarget}
onOpenChange={onOpenChange}
/>
),
) )
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => ( .with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
<DocumentActionAuthPasskey <DocumentActionAuthPasskey
actionTarget={actionTarget}
actionVerb={actionVerb}
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit} onReauthFormSubmit={onReauthFormSubmit}
/> />
)) ))
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( .with({ documentAuthType: DocumentAuth['2FA'] }, () => (
<DocumentActionAuth2FA <DocumentActionAuth2FA
actionTarget={actionTarget}
actionVerb={actionVerb}
open={open} open={open}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit} onReauthFormSubmit={onReauthFormSubmit}

View File

@@ -137,7 +137,10 @@ export const DocumentActionAuthPasskey = ({
); );
} }
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) { if (
passkeyData.isInitialLoading ||
(passkeyData.isRefetching && passkeyData.passkeys.length === 0)
) {
return ( return (
<div className="flex h-28 items-center justify-center"> <div className="flex h-28 items-center justify-center">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" /> <Loader className="text-muted-foreground h-6 w-6 animate-spin" />
@@ -168,7 +171,7 @@ export const DocumentActionAuthPasskey = ({
if (passkeyData.passkeys.length === 0) { if (passkeyData.passkeys.length === 0) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Alert variant="warning"> <Alert>
<AlertDescription> <AlertDescription>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup a passkey to mark this document as viewed.' ? 'You need to setup a passkey to mark this document as viewed.'

View File

@@ -5,6 +5,7 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth'; import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import type { import type {
TDocumentAuthOptions, TDocumentAuthOptions,
TRecipientAccessAuthTypes, TRecipientAccessAuthTypes,
@@ -13,13 +14,7 @@ import type {
} from '@documenso/lib/types/document-auth'; } from '@documenso/lib/types/document-auth';
import { DocumentAuth } from '@documenso/lib/types/document-auth'; import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { import type { Document, Passkey, Recipient, User } from '@documenso/prisma/client';
type Document,
FieldType,
type Passkey,
type Recipient,
type User,
} from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog'; import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog';
@@ -141,12 +136,12 @@ export const DocumentAuthProvider = ({
.with(DocumentAuth.EXPLICIT_NONE, () => ({ .with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE, type: DocumentAuth.EXPLICIT_NONE,
})) }))
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null) .with(DocumentAuth.PASSKEY, DocumentAuth['2FA'], null, () => null)
.exhaustive(); .exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
// Directly run callback if no auth required. // Directly run callback if no auth required.
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) { if (!derivedRecipientActionAuth) {
await options.onReauthFormSubmit(); await options.onReauthFormSubmit();
return; return;
} }
@@ -174,11 +169,9 @@ export const DocumentAuthProvider = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [passkeyData.passkeys]); }, [passkeyData.passkeys]);
// Assume that a user must be logged in for any auth requirements.
const isAuthRedirectRequired = Boolean( const isAuthRedirectRequired = Boolean(
derivedRecipientActionAuth && DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired &&
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE && !preCalculatedActionAuthOptions,
user?.email !== recipient.email,
); );
const refetchPasskeys = async () => { const refetchPasskeys = async () => {

View File

@@ -6,7 +6,6 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
@@ -32,12 +31,12 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation();
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;

View File

@@ -20,6 +20,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
import { useRequiredSigningContext } from './provider'; import { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog'; import { SignDialog } from './sign-dialog';
@@ -36,16 +37,17 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
const { data: session } = useSession(); const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const { mutateAsync: completeDocumentWithToken } = const { mutateAsync: completeDocumentWithToken } =
trpc.recipient.completeDocumentWithToken.useMutation(); trpc.recipient.completeDocumentWithToken.useMutation();
const { handleSubmit, formState } = useForm(); const {
handleSubmit,
// Keep the loading state going if successful since the redirect may take some time. formState: { isSubmitting },
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful; } = useForm();
const uninsertedFields = useMemo(() => { const uninsertedFields = useMemo(() => {
return sortFieldsByPosition(fields.filter((field) => !field.inserted)); return sortFieldsByPosition(fields.filter((field) => !field.inserted));
@@ -65,13 +67,10 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
return; return;
} }
await completeDocument(); await executeActionAuthProcedure({
onReauthFormSubmit: completeDocument,
// Reauth is currently not required for completing the document. actionTarget: 'DOCUMENT',
// await executeActionAuthProcedure({ });
// onReauthFormSubmit: completeDocument,
// actionTarget: 'DOCUMENT',
// });
}; };
const completeDocument = async (authOptions?: TRecipientActionAuth) => { const completeDocument = async (authOptions?: TRecipientActionAuth) => {

View File

@@ -6,10 +6,9 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -40,12 +39,12 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation();
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
@@ -70,7 +69,6 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
void executeActionAuthProcedure({ void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName), onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName),
actionTarget: field.type,
}); });
}; };

View File

@@ -12,6 +12,8 @@ import {
import { truncateTitle } from '~/helpers/truncate-title'; import { truncateTitle } from '~/helpers/truncate-title';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type SignDialogProps = { export type SignDialogProps = {
isSubmitting: boolean; isSubmitting: boolean;
document: Document; document: Document;
@@ -29,26 +31,28 @@ export const SignDialog = ({
onSignatureComplete, onSignatureComplete,
role, role,
}: SignDialogProps) => { }: SignDialogProps) => {
const { executeActionAuthProcedure, isAuthRedirectRequired, isCurrentlyAuthenticating } =
useRequiredDocumentAuthContext();
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title); const truncatedTitle = truncateTitle(document.title);
const isComplete = fields.every((field) => field.inserted); const isComplete = fields.every((field) => field.inserted);
const handleOpenChange = (open: boolean) => { const handleOpenChange = async (open: boolean) => {
if (isSubmitting || !isComplete) { if (isSubmitting || !isComplete) {
return; return;
} }
// Reauth is currently not required for signing the document. if (isAuthRedirectRequired) {
// if (isAuthRedirectRequired) { await executeActionAuthProcedure({
// await executeActionAuthProcedure({ actionTarget: 'DOCUMENT',
// actionTarget: 'DOCUMENT', onReauthFormSubmit: () => {
// onReauthFormSubmit: () => { // Do nothing since the user should be redirected.
// // Do nothing since the user should be redirected. },
// }, });
// });
// return; return;
// } }
setShowDialog(open); setShowDialog(open);
}; };
@@ -100,7 +104,7 @@ export const SignDialog = ({
type="button" type="button"
className="flex-1" className="flex-1"
disabled={!isComplete} disabled={!isComplete}
loading={isSubmitting} loading={isSubmitting || isCurrentlyAuthenticating}
onClick={onSignatureComplete} onClick={onSignatureComplete}
> >
{role === RecipientRole.VIEWER && 'Mark as Viewed'} {role === RecipientRole.VIEWER && 'Mark as Viewed'}

View File

@@ -6,10 +6,9 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { type Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -42,12 +41,12 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation();
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation();
const { Signature: signature } = field; const { Signature: signature } = field;
@@ -90,7 +89,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
void executeActionAuthProcedure({ void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature), onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
actionTarget: field.type,
}); });
}; };

View File

@@ -3,7 +3,6 @@
import React from 'react'; import React from 'react';
import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { FieldType } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
@@ -56,24 +55,11 @@ export const SigningFieldContainer = ({
return; return;
} }
// Bypass reauth for non signature fields.
if (field.type !== FieldType.SIGNATURE) {
const presignResult = await onPreSign?.();
if (presignResult === false) {
return;
}
await onSign();
return;
}
if (isAuthRedirectRequired) { if (isAuthRedirectRequired) {
await executeActionAuthProcedure({ await executeActionAuthProcedure({
onReauthFormSubmit: () => { onReauthFormSubmit: () => {
// Do nothing since the user should be redirected. // Do nothing since the user should be redirected.
}, },
actionTarget: field.type,
}); });
return; return;
@@ -90,7 +76,6 @@ export const SigningFieldContainer = ({
await executeActionAuthProcedure({ await executeActionAuthProcedure({
onReauthFormSubmit: onSign, onReauthFormSubmit: onSign,
actionTarget: field.type,
}); });
}; };

View File

@@ -6,7 +6,6 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
@@ -36,12 +35,12 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); trpc.field.signFieldWithToken.useMutation();
const { const {
mutateAsync: removeSignedFieldWithToken, mutateAsync: removeSignedFieldWithToken,
isLoading: isRemoveSignedFieldWithTokenLoading, isLoading: isRemoveSignedFieldWithTokenLoading,
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); } = trpc.field.removeSignedFieldWithToken.useMutation();
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
@@ -62,7 +61,6 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
void executeActionAuthProcedure({ void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions), onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
}); });
}; };

View File

@@ -14,10 +14,6 @@ import {
SETTINGS_PAGE_SHORTCUT, SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT, TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts'; } from '@documenso/lib/constants/keyboard-shortcuts';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { Document, Recipient } from '@documenso/prisma/client'; import type { Document, Recipient } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { import {
@@ -86,10 +82,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
}, },
{ {
keepPreviousData: true, keepPreviousData: true,
// Do not batch this due to relatively long request time compared to
// other queries which are generally batched with this.
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
}, },
); );

View File

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

View File

@@ -1,11 +1,8 @@
'use client'; import { useEffect, useMemo } from 'react';
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';
@@ -14,13 +11,11 @@ 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,
@@ -31,79 +26,98 @@ 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 ZEnable2FAForm = z.object({ export const ZSetupTwoFactorAuthenticationForm = 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 TEnable2FAForm = z.infer<typeof ZEnable2FAForm>; export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
export type EnableAuthenticatorAppDialogProps = { export type EnableAuthenticatorAppDialogProps = {
onSuccess?: () => void; open: boolean;
onOpenChange: (_open: boolean) => void;
}; };
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => { export const EnableAuthenticatorAppDialog = ({
open,
onOpenChange,
}: EnableAuthenticatorAppDialogProps) => {
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
trpc.twoFactorAuthentication.setup.useMutation();
const [isOpen, setIsOpen] = useState(false);
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
const { const {
mutateAsync: setup2FA, mutateAsync: enableTwoFactorAuthentication,
data: setup2FAData, data: enableTwoFactorAuthenticationData,
isLoading: isSettingUp2FA, isLoading: isEnableTwoFactorAuthenticationDataLoading,
} = trpc.twoFactorAuthentication.setup.useMutation({ } = trpc.twoFactorAuthentication.enable.useMutation();
onError: () => {
toast({ const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
title: 'Unable to setup two-factor authentication', defaultValues: {
description: password: '',
'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 enable2FAForm = useForm<TEnable2FAForm>({ const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
setupTwoFactorAuthenticationForm.formState;
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
defaultValues: { defaultValues: {
token: '', token: '',
}, },
resolver: zodResolver(ZEnable2FAForm), resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
}); });
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState; const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
enableTwoFactorAuthenticationForm.formState;
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => { const step = useMemo(() => {
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
return 'setup';
}
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
return 'enable';
}
return 'view';
}, [
setupTwoFactorAuthenticationData,
isSetupTwoFactorAuthenticationSubmitting,
enableTwoFactorAuthenticationData,
isEnableTwoFactorAuthenticationSubmitting,
]);
const onSetupTwoFactorAuthenticationFormSubmit = async ({
password,
}: TSetupTwoFactorAuthenticationForm) => {
try { try {
const data = await enable2FA({ code: token }); await setupTwoFactorAuthentication({ password });
setRecoveryCodes(data.recoveryCodes);
onSuccess?.();
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',
description: description:
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.', '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', variant: 'destructive',
}); });
} }
}; };
const downloadRecoveryCodes = () => { const downloadRecoveryCodes = () => {
if (recoveryCodes) { if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
const blob = new Blob([recoveryCodes.join('\n')], { const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
type: 'text/plain', type: 'text/plain',
}); });
@@ -114,126 +128,175 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
} }
}; };
const handleEnable2FA = async () => { const onEnableTwoFactorAuthenticationFormSubmit = async ({
if (!setup2FAData) { token,
await setup2FA(); }: TEnableTwoFactorAuthenticationForm) => {
} try {
await enableTwoFactorAuthentication({ code: token });
setIsOpen(true); 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',
});
}
}; };
useEffect(() => { useEffect(() => {
enable2FAForm.reset(); // Reset the form when the Dialog closes
if (!open) {
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) { setupTwoFactorAuthenticationForm.reset();
setRecoveryCodes(null);
router.refresh();
} }
}, [open, setupTwoFactorAuthenticationForm]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild={true}> <DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
<Button <DialogHeader>
className="flex-shrink-0" <DialogTitle>Enable Authenticator App</DialogTitle>
loading={isSettingUp2FA}
onClick={(e) => {
e.preventDefault();
void handleEnable2FA();
}}
>
Enable 2FA
</Button>
</DialogTrigger>
<DialogContent position="center"> {step === 'setup' && (
{setup2FAData && ( <DialogDescription>
<> To enable two-factor authentication, please enter your password below.
{recoveryCodes ? ( </DialogDescription>
<div> )}
<DialogHeader>
<DialogTitle>Backup codes</DialogTitle>
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
</DialogHeader>
<div className="mt-4"> {step === 'view' && (
<RecoveryCodeList recoveryCodes={recoveryCodes} /> <DialogDescription>
</div> Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="mt-4"> {match(step)
<DialogClose asChild> .with('setup', () => {
<Button variant="secondary">Close</Button> return (
</DialogClose> <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>
)}
/>
<Button onClick={downloadRecoveryCodes}>Download</Button> <DialogFooter>
</DialogFooter> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
</div> Cancel
) : ( </Button>
<Form {...enable2FAForm}>
<form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
<DialogHeader>
<DialogTitle>Enable Authenticator App</DialogTitle>
<DialogDescription>
To enable two-factor authentication, scan the following QR code using your
authenticator app.
</DialogDescription>
</DialogHeader>
<fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4"> <Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
<div Continue
className="flex h-36 justify-center" </Button>
dangerouslySetInnerHTML={{ </DialogFooter>
__html: renderSVG(setup2FAData?.uri ?? ''),
}}
/>
<p className="text-muted-foreground text-sm">
If your authenticator app does not support QR codes, you can use the following
code instead:
</p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setup2FAData?.secret}
</p>
<p className="text-muted-foreground text-sm">
Once you have scanned the QR code or entered the code manually, enter the code
provided by your authenticator app below.
</p>
<FormField
name="token"
control={enable2FAForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button type="submit" loading={isEnabling2FA}>
Enable 2FA
</Button>
</DialogFooter>
</fieldset>
</form> </form>
</Form> </Form>
)} );
</> })
)} .with('enable', () => (
<Form {...enableTwoFactorAuthenticationForm}>
<form
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
authenticator app.
</p>
<div
className="flex h-36 justify-center"
dangerouslySetInnerHTML={{
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
}}
/>
<p className="text-muted-foreground text-sm">
If your authenticator app does not support QR codes, you can use the following
code instead:
</p>
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
{setupTwoFactorAuthenticationData?.secret}
</p>
<p className="text-muted-foreground text-sm">
Once you have scanned the QR code or entered the code manually, enter the code
provided by your authenticator app below.
</p>
<FormField
name="token"
control={enableTwoFactorAuthenticationForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Token</FormLabel>
<FormControl>
<Input {...field} type="text" value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
Enable 2FA
</Button>
</DialogFooter>
</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

@@ -0,0 +1,34 @@
'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
variant="outline"
className="bg-background flex-shrink-0"
onClick={() => setIsOpen(true)}
disabled={!isTwoFactorEnabled}
>
View Codes
</Button>
<ViewRecoveryCodesDialog
key={isOpen ? 'open' : 'closed'}
open={isOpen}
onOpenChange={setIsOpen}
/>
</>
);
};

View File

@@ -1,6 +1,4 @@
'use client'; import { useEffect, useMemo } from 'react';
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';
@@ -8,61 +6,69 @@ 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 { Input } from '@documenso/ui/primitives/input'; import { PasswordInput } from '@documenso/ui/primitives/password-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({
token: z.string().min(1, { message: 'Token is required' }), password: z.string().min(6).max(72),
}); });
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>; export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
export const ViewRecoveryCodesDialog = () => { export type ViewRecoveryCodesDialogProps = {
const [isOpen, setIsOpen] = useState(false); open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
const { toast } = useToast();
const { const {
data: recoveryCodes, mutateAsync: viewRecoveryCodes,
mutate, data: viewRecoveryCodesData,
isLoading, isLoading: isViewRecoveryCodesDataLoading,
isError,
error,
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
// error?.data?.code
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({ const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
defaultValues: { defaultValues: {
token: '', password: '',
}, },
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 (recoveryCodes) { if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
const blob = new Blob([recoveryCodes.join('\n')], { const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
type: 'text/plain', type: 'text/plain',
}); });
@@ -73,88 +79,105 @@ export const ViewRecoveryCodesDialog = () => {
} }
}; };
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={isOpen} onOpenChange={setIsOpen}> <Dialog open={open} onOpenChange={onOpenChange}>
<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">
{recoveryCodes ? ( <DialogHeader>
<div> <DialogTitle>View Recovery Codes</DialogTitle>
<DialogHeader className="mb-4">
<DialogTitle>View Recovery Codes</DialogTitle>
<DialogDescription> {step === 'authenticate' && (
Your recovery codes are listed below. Please store them in a safe place. <DialogDescription>
</DialogDescription> To view your recovery codes, please enter your password below.
</DialogHeader> </DialogDescription>
)}
<RecoveryCodeList recoveryCodes={recoveryCodes} /> {step === 'view' && (
<DialogDescription>
Your recovery codes are listed below. Please store them in a safe place.
</DialogDescription>
)}
</DialogHeader>
<DialogFooter className="mt-4"> {match(step)
<DialogClose asChild> .with('authenticate', () => {
<Button variant="secondary">Close</Button> return (
</DialogClose> <Form {...viewRecoveryCodesForm}>
<form
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
className="flex flex-col gap-y-4"
>
<FormField
name="password"
control={viewRecoveryCodesForm.control}
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl>
<PasswordInput
{...field}
autoComplete="current-password"
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button onClick={downloadRecoveryCodes}>Download</Button> <DialogFooter>
</DialogFooter> <Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
</div>
) : (
<Form {...viewRecoveryCodesForm}>
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
<DialogHeader className="mb-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
name="token"
control={viewRecoveryCodesForm.control}
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} placeholder="Token" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>
{match(AppError.parseError(error).message)
.with(
ErrorCode.INCORRECT_TWO_FACTOR_CODE,
() => 'Invalid code. Please try again.',
)
.otherwise(
() => 'Something went wrong. Please try again or contact support.',
)}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
Cancel Cancel
</Button> </Button>
</DialogClose>
<Button type="submit" loading={isLoading}> <Button type="submit" loading={isViewRecoveryCodesSubmitting}>
View Continue
</Button> </Button>
</DialogFooter> </DialogFooter>
</fieldset> </form>
</form> </Form>
</Form> );
)} })
.with('view', () => (
<div>
{viewRecoveryCodesData?.recoveryCodes && (
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
)}
<div className="mt-4 flex flex-row-reverse items-center gap-2">
<Button onClick={() => onOpenChange(false)}>Complete</Button>
<Button
variant="secondary"
disabled={!viewRecoveryCodesData?.recoveryCodes}
loading={isViewRecoveryCodesDataLoading}
onClick={downloadRecoveryCodes}
>
Download
</Button>
</div>
</div>
))
.exhaustive()}
</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 max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase"> <div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm">
{baseUrl.host}/u/{field.value || '<username>'} {baseUrl.host}/u/{field.value || '<username>'}
</div> </div>
</FormItem> </FormItem>

View File

@@ -115,7 +115,6 @@ Here's a markdown table documenting all the provided environment variables:
| `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. | | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH` | The path to the key file, default `/opt/documenso/cert.p12`. |
| `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | The transport to use for file uploads (database or s3). |
| `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). | | `NEXT_PRIVATE_UPLOAD_ENDPOINT` | The endpoint for the S3 storage transport (for third-party S3-compatible providers). |
| `NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE` | Whether to force path-style URLs for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). | | `NEXT_PRIVATE_UPLOAD_REGION` | The region for the S3 storage transport (defaults to us-east-1). |
| `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. | | `NEXT_PRIVATE_UPLOAD_BUCKET` | The bucket to use for the S3 storage transport. |
| `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. | | `NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID` | The access key ID for the S3 storage transport. |

View File

@@ -34,7 +34,6 @@ services:
- NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL:-${NEXT_PRIVATE_DATABASE_URL}} - NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL:-${NEXT_PRIVATE_DATABASE_URL}}
- NEXT_PUBLIC_UPLOAD_TRANSPORT=${NEXT_PUBLIC_UPLOAD_TRANSPORT:-database} - NEXT_PUBLIC_UPLOAD_TRANSPORT=${NEXT_PUBLIC_UPLOAD_TRANSPORT:-database}
- NEXT_PRIVATE_UPLOAD_ENDPOINT=${NEXT_PRIVATE_UPLOAD_ENDPOINT} - NEXT_PRIVATE_UPLOAD_ENDPOINT=${NEXT_PRIVATE_UPLOAD_ENDPOINT}
- NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE=${NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE}
- NEXT_PRIVATE_UPLOAD_REGION=${NEXT_PRIVATE_UPLOAD_REGION} - NEXT_PRIVATE_UPLOAD_REGION=${NEXT_PRIVATE_UPLOAD_REGION}
- NEXT_PRIVATE_UPLOAD_BUCKET=${NEXT_PRIVATE_UPLOAD_BUCKET} - NEXT_PRIVATE_UPLOAD_BUCKET=${NEXT_PRIVATE_UPLOAD_BUCKET}
- NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=${NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID} - NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=${NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID}

View File

@@ -1,15 +1,7 @@
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}': [eslint, prettier], '**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`),
'**/*.{js,jsx,cjs,mjs}': [prettier], '**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*.{yml,mdx}': [prettier], '**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`),
'**/*/package.json': 'npm run precommit', '**/*/package.json': 'npm run precommit',
}; };

4095
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": "^9.0.11", "husky": "^8.0.0",
"lint-staged": "^15.2.2", "lint-staged": "^14.0.0",
"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,7 +48,6 @@
"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

@@ -124,8 +124,7 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
await unseedUser(recipientWithAccount.id); await unseedUser(recipientWithAccount.id);
}); });
// Currently document auth for signing/approving/viewing is not required. test('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
page, page,
}) => { }) => {
const user = await seedUser(); const user = await seedUser();
@@ -152,7 +151,7 @@ test.skip('[DOCUMENT_AUTH]: should deny signing document when required for globa
await page.getByRole('button', { name: 'Complete' }).click(); await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign the document', 'Reauthentication is required to sign this document',
); );
await unseedUser(user.id); await unseedUser(user.id);
@@ -185,10 +184,6 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
for (const field of Field) { for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field', 'Reauthentication is required to sign this field',
@@ -254,10 +249,6 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
if (isAuthRequired) { if (isAuthRequired) {
for (const field of Field) { for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field', 'Reauthentication is required to sign this field',
@@ -365,10 +356,6 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
if (isAuthRequired) { if (isAuthRequired) {
for (const field of Field) { for (const field of Field) {
if (field.type !== FieldType.SIGNATURE) {
continue;
}
await page.locator(`#field-${field.id}`).getByRole('button').click(); await page.locator(`#field-${field.id}`).getByRole('button').click();
await expect(page.getByRole('paragraph')).toContainText( await expect(page.getByRole('paragraph')).toContainText(
'Reauthentication is required to sign this field', 'Reauthentication is required to sign this field',

View File

@@ -5,140 +5,12 @@ import {
seedDraftDocument, seedDraftDocument,
seedPendingDocument, seedPendingDocument,
} from '@documenso/prisma/seed/documents'; } from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication'; import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[DOCUMENT_FLOW] add action auth settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Return to the settings step to check that the results are saved correctly.
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
// Todo: Verify that the values are correct once we fix the issue where going back
// does not show the updated values.
// await expect(page.getByLabel('Title')).toContainText('New Title');
// await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
await unseedUser(user.id);
});
test('[DOCUMENT_FLOW] enterprise team member can add action auth settings', async ({ page }) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const owner = team.owner;
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(owner, {
createDocumentOptions: {
teamId: team.id,
},
});
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
// Set EE action auth.
await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should be visible.
await expect(page.getByLabel('Show advanced settings')).toBeVisible();
await unseedTeam(team.url);
});
test('[DOCUMENT_FLOW] enterprise team member should not have access to enterprise on personal account', async ({
page,
}) => {
const team = await seedTeam({
createTeamMembers: 1,
});
const teamMemberUser = team.members[1].user;
// Make the team enterprise by giving the owner the enterprise subscription.
await seedUserSubscription({
userId: team.ownerUserId,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(teamMemberUser);
await apiSignin({
page,
email: teamMemberUser.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Global action auth should not be visible.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
// Next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Advanced settings should not be visible.
await expect(page.getByLabel('Show advanced settings')).not.toBeVisible();
await unseedTeam(team.url);
});
});
test('[DOCUMENT_FLOW]: add settings', async ({ page }) => { test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
const document = await seedBlankDocument(user); const document = await seedBlankDocument(user);
@@ -157,8 +29,10 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
await page.getByLabel('Require account').getByText('Require account').click(); await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account'); await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
// Action auth should NOT be visible. // Set action auth.
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible(); await page.getByTestId('documentActionSelectValue').click();
await page.getByLabel('Require account').getByText('Require account').click();
await expect(page.getByTestId('documentActionSelectValue')).toContainText('Require account');
// Save the settings by going to the next step. // Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();

View File

@@ -1,67 +1,12 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
import { seedUser, unseedUser } from '@documenso/prisma/seed/users'; import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication'; import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel' }); test.describe.configure({ mode: 'parallel' });
test.describe('[EE_ONLY]', () => {
const enterprisePriceId = process.env.NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID || '';
test.beforeEach(() => {
test.skip(
process.env.NEXT_PUBLIC_FEATURE_BILLING_ENABLED !== 'true' || !enterprisePriceId,
'Billing required for this test',
);
});
test('[DOCUMENT_FLOW] add EE settings', async ({ page }) => {
const user = await seedUser();
await seedUserSubscription({
userId: user.id,
priceId: enterprisePriceId,
});
const document = await seedBlankDocument(user);
await apiSignin({
page,
email: user.email,
redirectPath: `/documents/${document.id}/edit`,
});
// Save the settings by going to the next step.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('recipient1@documenso.com');
await page.getByPlaceholder('Name').fill('Recipient 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page
.getByRole('textbox', { name: 'Email', exact: true })
.fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Display advanced settings.
await page.getByLabel('Show advanced settings').click();
// Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
// Todo: Fix stepper component back issue before finishing test.
await unseedUser(user.id);
});
});
// Note: Not complete yet due to issue with back button. // Note: Not complete yet due to issue with back button.
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => { test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
const user = await seedUser(); const user = await seedUser();
@@ -84,8 +29,8 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com'); await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2'); await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
// Advanced settings should not be visible for non EE users. // Display advanced settings.
await expect(page.getByLabel('Show advanced settings')).toBeHidden(); await page.getByLabel('Show advanced settings').click();
// Navigate to the next step and back. // Navigate to the next step and back.
await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Continue' }).click();

View File

@@ -1,24 +1,17 @@
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 { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
import { DocumentStatus } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from './fixtures/authentication';
test(`[PR-718]: should be able to create a document`, async ({ page }) => { test(`[PR-718]: should be able to create a document`, async ({ page }) => {
await page.goto('/signin'); await page.goto('/signin');
const documentTitle = `example-${Date.now()}.pdf`; const documentTitle = `example-${Date.now()}.pdf`;
const user = await seedUser(); // Sign in
await page.getByLabel('Email').fill(TEST_USER.email);
await apiSignin({ await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
page, await page.getByRole('button', { name: 'Sign In' }).click();
email: user.email,
});
// Upload document // Upload document
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
@@ -80,267 +73,3 @@ 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`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// 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: 'General' })).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();
// Add 2 signers.
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('Name').fill('User 1');
await page.getByRole('button', { name: 'Add Signer' }).click();
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
await page.getByRole('textbox', { name: 'Name', exact: true }).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`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// 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: 'General' })).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.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('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();
await page.waitForURL(/\/documents\/\d+/);
const url = 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`;
const user = await seedUser();
await apiSignin({
page,
email: user.email,
});
// 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 & advanced redirect
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.getByLabel('Title').fill(documentTitle);
await page.getByRole('button', { name: 'Advanced Options' }).click();
await page.getByLabel('Redirect URL').fill('https://documenso.com');
await page.getByRole('button', { name: 'Continue' }).click();
// Add signers
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByPlaceholder('Email').fill('user1@example.com');
await page.getByPlaceholder('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();
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();
await page.waitForURL(/\/documents\/\d+/);
const url = 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

@@ -1,37 +0,0 @@
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,7 +6,6 @@
"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

@@ -1,14 +1,10 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`]; /**
* Read environment variables from file.
ENV_FILES.forEach((file) => { * https://github.com/motdotla/dotenv
dotenv.config({ */
path: path.join(__dirname, `../../${file}`), // require('dotenv').config();
});
});
/** /**
* See https://playwright.dev/docs/test-configuration. * See https://playwright.dev/docs/test-configuration.

View File

@@ -1,56 +0,0 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { subscriptionsContainActiveEnterprisePlan } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import type { Subscription } from '@documenso/prisma/client';
export type IsUserEnterpriseOptions = {
userId: number;
teamId?: number;
};
/**
* Whether the user is enterprise, or has permission to use enterprise features on
* behalf of their team.
*
* It is assumed that the provided user is part of the provided team.
*/
export const isUserEnterprise = async ({
userId,
teamId,
}: IsUserEnterpriseOptions): Promise<boolean> => {
let subscriptions: Subscription[] = [];
if (!IS_BILLING_ENABLED()) {
return false;
}
if (teamId) {
subscriptions = await prisma.team
.findFirstOrThrow({
where: {
id: teamId,
},
select: {
owner: {
include: {
Subscription: true,
},
},
},
})
.then((team) => team.owner.Subscription);
} else {
subscriptions = await prisma.user
.findFirstOrThrow({
where: {
id: userId,
},
select: {
Subscription: true,
},
})
.then((user) => user.Subscription);
}
return subscriptionsContainActiveEnterprisePlan(subscriptions);
};

View File

@@ -4,15 +4,16 @@ 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: ['package-json', 'unused-imports'], plugins: ['prettier', '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,14 +7,16 @@
"clean": "rimraf node_modules" "clean": "rimraf node_modules"
}, },
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/eslint-plugin": "6.8.0",
"@typescript-eslint/parser": "^7.1.1", "@typescript-eslint/parser": "6.8.0",
"eslint": "^8.57.0", "eslint": "^8.40.0",
"eslint-config-next": "^14.1.3", "eslint-config-next": "13.4.19",
"eslint-config-turbo": "^1.12.5", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-package-json": "^0.10.4", "eslint-config-turbo": "^1.9.3",
"eslint-plugin-react": "^7.34.0", "eslint-plugin-package-json": "^0.2.0",
"eslint-plugin-unused-imports": "^3.1.0", "eslint-plugin-prettier": "^4.2.1",
"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

@@ -4,19 +4,28 @@ import { DocumentAuth } from '../types/document-auth';
type DocumentAuthTypeData = { type DocumentAuthTypeData = {
key: TDocumentAuth; key: TDocumentAuth;
value: string; value: string;
/**
* Whether this authentication event will require the user to halt and
* redirect.
*
* Defaults to false.
*/
isAuthRedirectRequired?: boolean;
}; };
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = { export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
[DocumentAuth.ACCOUNT]: { [DocumentAuth.ACCOUNT]: {
key: DocumentAuth.ACCOUNT, key: DocumentAuth.ACCOUNT,
value: 'Require account', value: 'Require account',
isAuthRedirectRequired: true,
}, },
[DocumentAuth.PASSKEY]: { [DocumentAuth.PASSKEY]: {
key: DocumentAuth.PASSKEY, key: DocumentAuth.PASSKEY,
value: 'Require passkey', value: 'Require passkey',
}, },
[DocumentAuth.TWO_FACTOR_AUTH]: { [DocumentAuth['2FA']]: {
key: DocumentAuth.TWO_FACTOR_AUTH, key: DocumentAuth['2FA'],
value: 'Require 2FA', value: 'Require 2FA',
}, },
[DocumentAuth.EXPLICIT_NONE]: { [DocumentAuth.EXPLICIT_NONE]: {

View File

@@ -1,25 +0,0 @@
/**
* For TRPC useQueries that should not be batched with other queries.
*/
export const SKIP_QUERY_BATCH_META = {
trpc: {
context: {
skipBatch: true,
},
},
};
/**
* For TRPC useQueries and useMutations to adjust the logic on when query invalidation
* should occur.
*
* When used in:
* - useQuery: Will not invalidate the given query when a mutation occurs.
* - useMutation: Will not trigger invalidation on all queries when mutation succeeds.
*
*/
export const DO_NOT_INVALIDATE_QUERY_ON_MUTATION = {
meta: {
doNotInvalidateQueryOnMutation: true,
},
};

View File

@@ -212,35 +212,36 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const requestMetadata = extractNextAuthRequestMetadata(req); const requestMetadata = extractNextAuthRequestMetadata(req);
if (!verification?.verified) { // Explicit success state to reduce chances of bugs.
await prisma.userSecurityAuditLog.create({ if (verification?.verified === true) {
await prisma.passkey.update({
where: {
id: passkey.id,
},
data: { data: {
userId: user.id, lastUsedAt: new Date(),
ipAddress: requestMetadata.ipAddress, counter: verification.authenticationInfo.newCounter,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
}, },
}); });
return null; return {
id: Number(user.id),
email: user.email,
name: user.name,
emailVerified: user.emailVerified?.toISOString() ?? null,
} satisfies User;
} }
await prisma.passkey.update({ await prisma.userSecurityAuditLog.create({
where: {
id: passkey.id,
},
data: { data: {
lastUsedAt: new Date(), userId: user.id,
counter: verification.authenticationInfo.newCounter, ipAddress: requestMetadata.ipAddress,
userAgent: requestMetadata.userAgent,
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
}, },
}); });
return { return null;
id: Number(user.id),
email: user.email,
name: user.name,
emailVerified: user.emailVerified?.toISOString() ?? null,
} satisfies User;
}, },
}), }),
], ],

View File

@@ -1,30 +1,40 @@
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 { AppError } from '../../errors/app-error'; import { ErrorCode } from '../../next-auth/error-codes';
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;
token: string; backupCode: string;
password: string;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
export const disableTwoFactorAuthentication = async ({ export const disableTwoFactorAuthentication = async ({
token, backupCode,
user, user,
password,
requestMetadata, requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => { }: DisableTwoFactorAuthenticationOptions) => {
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user }); if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
if (!isValid) {
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
} }
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 AppError('INCORRECT_TWO_FACTOR_CODE'); throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_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,38 +17,25 @@ 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 AppError('TWO_FACTOR_ALREADY_ENABLED'); throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
} }
if (!user.twoFactorSecret) { if (!user.twoFactorSecret) {
throw new AppError('TWO_FACTOR_SETUP_REQUIRED'); throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
} }
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code }); const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
if (!isValidToken) { if (!isValidToken) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE'); throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
} }
let recoveryCodes: string[] = []; const updatedUser = await prisma.$transaction(async (tx) => {
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');
}
await tx.userSecurityAuditLog.create({ await tx.userSecurityAuditLog.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -57,7 +44,18 @@ 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,3 +1,4 @@
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';
@@ -11,12 +12,14 @@ 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;
@@ -24,6 +27,20 @@ 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

@@ -1,30 +0,0 @@
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

@@ -7,8 +7,11 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client'; import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TRecipientActionAuth } from '../../types/document-auth'; import type { TRecipientActionAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sealDocument } from './seal-document'; import { sealDocument } from './seal-document';
import { sendPendingEmail } from './send-pending-email'; import { sendPendingEmail } from './send-pending-email';
@@ -43,6 +46,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
export const completeDocumentWithToken = async ({ export const completeDocumentWithToken = async ({
token, token,
documentId, documentId,
userId,
authOptions,
requestMetadata, requestMetadata,
}: CompleteDocumentWithTokenOptions) => { }: CompleteDocumentWithTokenOptions) => {
'use server'; 'use server';
@@ -74,24 +79,22 @@ export const completeDocumentWithToken = async ({
throw new Error(`Recipient ${recipient.id} has unsigned fields`); throw new Error(`Recipient ${recipient.id} has unsigned fields`);
} }
// Document reauth for completing documents is currently not required. const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions,
recipientAuth: recipient.authOptions,
});
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ const isValid = await isRecipientAuthorized({
// documentAuth: document.authOptions, type: 'ACTION',
// recipientAuth: recipient.authOptions, document: document,
// }); recipient: recipient,
userId,
authOptions,
});
// const isValid = await isRecipientAuthorized({ if (!isValid) {
// type: 'ACTION', throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// document: document, }
// recipient: recipient,
// userId,
// authOptions,
// });
// if (!isValid) {
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
// }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.recipient.update({ await tx.recipient.update({
@@ -118,7 +121,7 @@ export const completeDocumentWithToken = async ({
recipientName: recipient.name, recipientName: recipient.name,
recipientId: recipient.id, recipientId: recipient.id,
recipientRole: recipient.role, recipientRole: recipient.role,
// actionAuth: derivedRecipientActionAuth || undefined, actionAuth: derivedRecipientActionAuth || undefined,
}, },
}), }),
}); });

View File

@@ -30,27 +30,6 @@ 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

@@ -1,32 +0,0 @@
import { prisma } from '@documenso/prisma';
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
import { getDocumentWhereInput } from './get-document-by-id';
export type GetDocumentWithDetailsByIdOptions = {
id: number;
userId: number;
teamId?: number;
};
export const getDocumentWithDetailsById = async ({
id,
userId,
teamId,
}: GetDocumentWithDetailsByIdOptions): Promise<DocumentWithDetails> => {
const documentWhereInput = await getDocumentWhereInput({
documentId: id,
userId,
teamId,
});
return await prisma.document.findFirstOrThrow({
where: documentWhereInput,
include: {
documentData: true,
documentMeta: true,
Recipient: true,
Field: true,
},
});
};

View File

@@ -31,7 +31,7 @@ type IsRecipientAuthorizedOptions = {
authOptions?: TDocumentAuthMethods; authOptions?: TDocumentAuthMethods;
}; };
const getUserByEmail = async (email: string) => { const getRecipient = async (email: string) => {
return await prisma.user.findFirst({ return await prisma.user.findFirst({
where: { where: {
email, email,
@@ -76,13 +76,17 @@ export const isRecipientAuthorized = async ({
} }
// Authentication required does not match provided method. // Authentication required does not match provided method.
if (!authOptions || authOptions.type !== authMethod || !userId) { if (!authOptions || authOptions.type !== authMethod) {
return false; return false;
} }
return await match(authOptions) return await match(authOptions)
.with({ type: DocumentAuth.ACCOUNT }, async () => { .with({ type: DocumentAuth.ACCOUNT }, async () => {
const recipientUser = await getUserByEmail(recipient.email); if (userId === undefined) {
return false;
}
const recipientUser = await getRecipient(recipient.email);
if (!recipientUser) { if (!recipientUser) {
return false; return false;
@@ -91,13 +95,21 @@ export const isRecipientAuthorized = async ({
return recipientUser.id === userId; return recipientUser.id === userId;
}) })
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => { .with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
if (!userId) {
return false;
}
return await isPasskeyAuthValid({ return await isPasskeyAuthValid({
userId, userId,
authenticationResponse, authenticationResponse,
tokenReference, tokenReference,
}); });
}) })
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => { .with({ type: DocumentAuth['2FA'] }, async ({ token }) => {
if (!userId) {
return false;
}
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { where: {
id: userId, id: userId,
@@ -132,6 +144,12 @@ type VerifyPasskeyOptions = {
* The response from the passkey authenticator. * The response from the passkey authenticator.
*/ */
authenticationResponse: TAuthenticationResponseJSONSchema; authenticationResponse: TAuthenticationResponseJSONSchema;
/**
* Whether to throw errors when the user fails verification instead of returning
* false.
*/
throwError?: boolean;
}; };
/** /**
@@ -162,10 +180,6 @@ const verifyPasskey = async ({
}, },
}); });
if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
}
const verificationToken = await prisma.verificationToken const verificationToken = await prisma.verificationToken
.delete({ .delete({
where: { where: {
@@ -175,6 +189,10 @@ const verifyPasskey = async ({
}) })
.catch(() => null); .catch(() => null);
if (!passkey) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
}
if (!verificationToken) { if (!verificationToken) {
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found'); throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
} }

View File

@@ -1,6 +1,5 @@
'use server'; 'use server';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs'; import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
@@ -75,21 +74,6 @@ export const updateDocumentSettings = async ({
const newGlobalActionAuth = const newGlobalActionAuth =
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth; data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
// Check if user has permission to set the global action auth.
if (newGlobalActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const isTitleSame = data.title === document.title; const isTitleSame = data.title === document.title;
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth; const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth; const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;

View File

@@ -5,7 +5,7 @@ import {
diffFieldChanges, diffFieldChanges,
} from '@documenso/lib/utils/document-audit-logs'; } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Field, FieldType } from '@documenso/prisma/client'; import type { FieldType } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetFieldsForDocumentOptions { export interface SetFieldsForDocumentOptions {
@@ -29,7 +29,7 @@ export const setFieldsForDocument = async ({
documentId, documentId,
fields, fields,
requestMetadata, requestMetadata,
}: SetFieldsForDocumentOptions): Promise<Field[]> => { }: SetFieldsForDocumentOptions) => {
const document = await prisma.document.findFirst({ const document = await prisma.document.findFirst({
where: { where: {
id: documentId, id: documentId,
@@ -99,7 +99,7 @@ export const setFieldsForDocument = async ({
}); });
const persistedFields = await prisma.$transaction(async (tx) => { const persistedFields = await prisma.$transaction(async (tx) => {
return await Promise.all( await Promise.all(
linkedFields.map(async (field) => { linkedFields.map(async (field) => {
const fieldSignerEmail = field.signerEmail.toLowerCase(); const fieldSignerEmail = field.signerEmail.toLowerCase();
@@ -218,13 +218,5 @@ export const setFieldsForDocument = async ({
}); });
} }
// Filter out fields that have been removed or have been updated. return persistedFields;
const filteredFields = existingFields.filter((field) => {
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
return !isRemoved && !isUpdated;
});
return [...filteredFields, ...persistedFields];
}; };

View File

@@ -79,28 +79,18 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`); throw new Error(`Field ${fieldId} has no recipientId`);
} }
let { derivedRecipientActionAuth } = extractDocumentAuthMethods({ const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: document.authOptions,
recipientAuth: recipient.authOptions, recipientAuth: recipient.authOptions,
}); });
// Override all non-signature fields to not require any auth. const isValid = await isRecipientAuthorized({
if (field.type !== FieldType.SIGNATURE) { type: 'ACTION',
derivedRecipientActionAuth = null; document: document,
} recipient: recipient,
userId,
let isValid = true; authOptions,
});
// Only require auth on signature fields for now.
if (field.type === FieldType.SIGNATURE) {
isValid = await isRecipientAuthorized({
type: 'ACTION',
document: document,
recipient: recipient,
userId,
authOptions,
});
}
if (!isValid) { if (!isValid) {
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');

View File

@@ -1,4 +1,3 @@
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import { import {
type TRecipientActionAuthTypes, type TRecipientActionAuthTypes,
@@ -12,12 +11,9 @@ import {
} from '@documenso/lib/utils/document-audit-logs'; } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth'; import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client'; import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
export interface SetRecipientsForDocumentOptions { export interface SetRecipientsForDocumentOptions {
userId: number; userId: number;
teamId?: number; teamId?: number;
@@ -38,7 +34,7 @@ export const setRecipientsForDocument = async ({
documentId, documentId,
recipients, recipients,
requestMetadata, requestMetadata,
}: SetRecipientsForDocumentOptions): Promise<Recipient[]> => { }: SetRecipientsForDocumentOptions) => {
const document = await prisma.document.findFirst({ const document = await prisma.document.findFirst({
where: { where: {
id: documentId, id: documentId,
@@ -79,23 +75,6 @@ export const setRecipientsForDocument = async ({
throw new Error('Document already complete'); throw new Error('Document already complete');
} }
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
// Check if user has permission to set the global action auth.
if (recipientsHaveActionAuth) {
const isDocumentEnterprise = await isUserEnterprise({
userId,
teamId,
});
if (!isDocumentEnterprise) {
throw new AppError(
AppErrorCode.UNAUTHORIZED,
'You do not have permission to set the action auth',
);
}
}
const normalizedRecipients = recipients.map((recipient) => ({ const normalizedRecipients = recipients.map((recipient) => ({
...recipient, ...recipient,
email: recipient.email.toLowerCase(), email: recipient.email.toLowerCase(),
@@ -267,17 +246,5 @@ export const setRecipientsForDocument = async ({
}); });
} }
// Filter out recipients that have been removed or have been updated. return persistedRecipients;
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
const isRemoved = removedRecipients.find(
(removedRecipient) => removedRecipient.id === recipient.id,
);
const isUpdated = persistedRecipients.find(
(persistedRecipient) => persistedRecipient.id === recipient.id,
);
return !isRemoved && !isUpdated;
});
return [...filteredRecipients, ...persistedRecipients];
}; };

View File

@@ -1,35 +0,0 @@
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

@@ -5,12 +5,7 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
/** /**
* All the available types of document authentication options for both access and action. * All the available types of document authentication options for both access and action.
*/ */
export const ZDocumentAuthTypesSchema = z.enum([ export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', '2FA', 'EXPLICIT_NONE']);
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'EXPLICIT_NONE',
]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({ const ZDocumentAuthAccountSchema = z.object({
@@ -28,7 +23,7 @@ const ZDocumentAuthPasskeySchema = z.object({
}); });
const ZDocumentAuth2FASchema = z.object({ const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH), type: z.literal(DocumentAuth['2FA']),
token: z.string().min(4).max(10), token: z.string().min(4).max(10),
}); });
@@ -63,7 +58,7 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
export const ZDocumentActionAuthTypesSchema = z.enum([ export const ZDocumentActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT, DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY, DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth['2FA'],
]); ]);
/** /**
@@ -90,7 +85,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
export const ZRecipientActionAuthTypesSchema = z.enum([ export const ZRecipientActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT, DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY, DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth['2FA'],
DocumentAuth.EXPLICIT_NONE, DocumentAuth.EXPLICIT_NONE,
]); ]);

View File

@@ -132,7 +132,6 @@ const getS3Client = () => {
return new S3Client({ return new S3Client({
endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined, endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined,
forcePathStyle: process.env.NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE === 'true',
region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1', region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1',
credentials: hasCredentials credentials: hasCredentials
? { ? {

View File

@@ -1,6 +1,3 @@
import { env } from 'next-runtime-env';
import { IS_BILLING_ENABLED } from '../constants/app';
import type { Subscription } from '.prisma/client'; import type { Subscription } from '.prisma/client';
import { SubscriptionStatus } from '.prisma/client'; import { SubscriptionStatus } from '.prisma/client';
@@ -16,15 +13,3 @@ export const subscriptionsContainsActivePlan = (
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId), subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
); );
}; };
export const subscriptionsContainActiveEnterprisePlan = (
subscriptions?: Subscription[],
): boolean => {
const enterprisePlanId = env('NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID');
if (!enterprisePlanId || !subscriptions || !IS_BILLING_ENABLED()) {
return false;
}
return subscriptionsContainsActivePlan(subscriptions, [enterprisePlanId]);
};

View File

@@ -182,7 +182,6 @@ 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,29 @@
import { hashSync } from '@documenso/lib/server-only/auth/hash';
import { prisma } from '..';
//
// https://github.com/documenso/documenso/pull/713
//
const PULL_REQUEST_NUMBER = 718;
const EMAIL_DOMAIN = `pr-${PULL_REQUEST_NUMBER}.documenso.com`;
export const TEST_USER = {
name: 'User 1',
email: `user1@${EMAIL_DOMAIN}`,
password: 'Password123',
} as const;
export const seedDatabase = async () => {
await prisma.user.create({
data: {
name: TEST_USER.name,
email: TEST_USER.email,
password: hashSync(TEST_USER.password),
emailVerified: new Date(),
url: TEST_USER.email,
},
});
};

View File

@@ -1,19 +0,0 @@
import { prisma } from '..';
export const seedTestEmail = () => `user-${Date.now()}@test.documenso.com`;
type SeedSubscriptionOptions = {
userId: number;
priceId: string;
};
export const seedUserSubscription = async ({ userId, priceId }: SeedSubscriptionOptions) => {
return await prisma.subscription.create({
data: {
userId,
planId: Date.now().toString(),
priceId,
status: 'ACTIVE',
},
});
};

View File

@@ -1,10 +1,4 @@
import type { import { Document, Recipient } from '@documenso/prisma/client';
Document,
DocumentData,
DocumentMeta,
Field,
Recipient,
} from '@documenso/prisma/client';
export type DocumentWithRecipientAndSender = Omit<Document, 'document'> & { export type DocumentWithRecipientAndSender = Omit<Document, 'document'> & {
recipient: Recipient; recipient: Recipient;
@@ -16,10 +10,3 @@ export type DocumentWithRecipientAndSender = Omit<Document, 'document'> & {
subject: string; subject: string;
description: string; description: string;
}; };
export type DocumentWithDetails = Document & {
documentData: DocumentData;
documentMeta: DocumentMeta | null;
Recipient: Recipient[];
Field: Field[];
};

View File

@@ -1,3 +0,0 @@
// 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,3 +1,4 @@
import signer from 'node-signpdf';
import { import {
PDFArray, PDFArray,
PDFDocument, PDFDocument,
@@ -8,8 +9,6 @@ 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;
}; };
@@ -21,9 +20,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(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)); byteRange.push(PDFName.of(signer.byteRangePlaceholder));
const signature = doc.context.obj({ const signature = doc.context.obj({
Type: 'Sig', Type: 'Sig',

View File

@@ -1,72 +0,0 @@
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

@@ -1,39 +0,0 @@
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,6 +1,5 @@
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 = {
@@ -12,7 +11,6 @@ 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": "*",
"@documenso/pdf-sign": "^0.1.0", "node-forge": "^1.3.1",
"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": {
"vitest": "^1.3.1" "@types/node-forge": "^1.3.4"
} }
} }

View File

@@ -1,79 +0,0 @@
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,51 +1,32 @@
import signer from 'node-signpdf';
import fs from 'node:fs'; import fs from 'node:fs';
import { signWithP12 } from '@documenso/pdf-sign'; import { addSigningPlaceholder } from '../helpers/addSigningPlaceholder';
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 { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({ const pdfWithPlaceholder = await addSigningPlaceholder({ pdf });
pdf: await addSigningPlaceholder({ pdf }),
});
const pdfWithoutSignature = Buffer.concat([ let p12Cert: Buffer | null = null;
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) {
cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64'); p12Cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64');
} }
if (!cert) { if (!p12Cert) {
cert = Buffer.from( p12Cert = 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'),
); );
} }
const signature = signWithP12({ if (process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE) {
cert, return signer.sign(pdfWithPlaceholder, p12Cert, {
content: pdfWithoutSignature, passphrase: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE,
password: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE || undefined, });
}); }
const signatureAsHex = signature.toString('hex'); return signer.sign(pdfWithPlaceholder, p12Cert);
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,22 +1,16 @@
import { createTRPCProxyClient, httpBatchLink, httpLink, splitLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import SuperJSON from 'superjson'; import SuperJSON from 'superjson';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url'; import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router'; import { AppRouter } from '../server/router';
export const trpc = createTRPCProxyClient<AppRouter>({ export const trpc = createTRPCProxyClient<AppRouter>({
transformer: SuperJSON, transformer: SuperJSON,
links: [ links: [
splitLink({ httpBatchLink({
condition: (op) => op.context.skipBatch === true, url: `${getBaseUrl()}/api/trpc`,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
}), }),
], ],
}); });

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import type { QueryClientConfig } from '@tanstack/react-query'; import type { QueryClientConfig } from '@tanstack/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, httpLink, splitLink } from '@trpc/client'; import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query'; import { createTRPCReact } from '@trpc/react-query';
import SuperJSON from 'superjson'; import SuperJSON from 'superjson';
@@ -12,22 +12,12 @@ import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
import type { AppRouter } from '../server/router'; import type { AppRouter } from '../server/router';
export { getQueryKey } from '@trpc/react-query';
export const trpc = createTRPCReact<AppRouter>({ export const trpc = createTRPCReact<AppRouter>({
overrides: { unstable_overrides: {
useMutation: { useMutation: {
async onSuccess(opts) { async onSuccess(opts) {
await opts.originalFn(); await opts.originalFn();
await opts.queryClient.invalidateQueries();
if (opts.meta.doNotInvalidateQueryOnMutation) {
return;
}
// Invalidate all queries besides ones that specify not to in the meta data.
await opts.queryClient.invalidateQueries({
predicate: (query) => !query?.meta?.doNotInvalidateQueryOnMutation,
});
}, },
}, },
}, },
@@ -65,14 +55,8 @@ export function TrpcProvider({ children }: TrpcProviderProps) {
transformer: SuperJSON, transformer: SuperJSON,
links: [ links: [
splitLink({ httpBatchLink({
condition: (op) => op.context.skipBatch === true, url: `${getBaseUrl()}/api/trpc`,
true: httpLink({
url: `${getBaseUrl()}/api/trpc`,
}),
false: httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
}), }),
], ],
}), }),

View File

@@ -1,6 +1,5 @@
import type { RegistrationResponseJSON } from '@simplewebauthn/types'; import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { parse } from 'cookie-es';
import { env } from 'next-runtime-env'; import { env } from 'next-runtime-env';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
@@ -153,13 +152,15 @@ export const authRouter = router({
}), }),
createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => { createPasskeySigninOptions: procedure.mutation(async ({ ctx }) => {
const sessionIdToken = parse(ctx.req.headers.cookie ?? '')['next-auth.csrf-token']; const cookie = ctx.req.headers.cookie ?? '';
const sessionIdToken = cookie?.split(';').find((c) => c.includes('next-auth.csrf-token'));
if (!sessionIdToken) { if (!sessionIdToken) {
throw new Error('Missing CSRF token'); throw new Error('Missing CSRF token');
} }
const [sessionId] = decodeURI(sessionIdToken).split('|'); const sessionId = decodeURI(sessionIdToken.split('=')[1]).split('|')[0];
try { try {
return await createPasskeySigninOptions({ sessionId }); return await createPasskeySigninOptions({ sessionId });

View File

@@ -9,7 +9,6 @@ import { duplicateDocumentById } from '@documenso/lib/server-only/document/dupli
import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs'; import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-document-audit-logs';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword'; import { searchDocumentsWithKeyword } from '@documenso/lib/server-only/document/search-documents-with-keyword';
import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document';
@@ -25,7 +24,6 @@ import {
ZFindDocumentAuditLogsQuerySchema, ZFindDocumentAuditLogsQuerySchema,
ZGetDocumentByIdQuerySchema, ZGetDocumentByIdQuerySchema,
ZGetDocumentByTokenQuerySchema, ZGetDocumentByTokenQuerySchema,
ZGetDocumentWithDetailsByIdQuerySchema,
ZResendDocumentMutationSchema, ZResendDocumentMutationSchema,
ZSearchDocumentsMutationSchema, ZSearchDocumentsMutationSchema,
ZSendDocumentMutationSchema, ZSendDocumentMutationSchema,
@@ -73,24 +71,6 @@ export const documentRouter = router({
} }
}), }),
getDocumentWithDetailsById: authenticatedProcedure
.input(ZGetDocumentWithDetailsByIdQuerySchema)
.query(async ({ input, ctx }) => {
try {
return await getDocumentWithDetailsById({
...input,
userId: ctx.user.id,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to find this document. Please try again later.',
});
}
}),
createDocument: authenticatedProcedure createDocument: authenticatedProcedure
.input(ZCreateDocumentMutationSchema) .input(ZCreateDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@@ -33,15 +33,6 @@ export const ZGetDocumentByTokenQuerySchema = z.object({
export type TGetDocumentByTokenQuerySchema = z.infer<typeof ZGetDocumentByTokenQuerySchema>; export type TGetDocumentByTokenQuerySchema = z.infer<typeof ZGetDocumentByTokenQuerySchema>;
export const ZGetDocumentWithDetailsByIdQuerySchema = z.object({
id: z.number().min(1),
teamId: z.number().min(1).optional(),
});
export type TGetDocumentWithDetailsByIdQuerySchema = z.infer<
typeof ZGetDocumentWithDetailsByIdQuerySchema
>;
export const ZCreateDocumentMutationSchema = z.object({ export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1), title: z.string().min(1),
documentDataId: z.string().min(1), documentDataId: z.string().min(1),

View File

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

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