Compare commits
17 Commits
feat/add-d
...
feat/docum
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55e1c1afd0 | ||
|
|
fd881572f8 | ||
|
|
3282481ad7 | ||
|
|
1ed18059fb | ||
|
|
d45bed6930 | ||
|
|
87b79451d5 | ||
|
|
e4ad940a06 | ||
|
|
cb020cc7d0 | ||
|
|
5033799724 | ||
|
|
b22de4bd71 | ||
|
|
aa926d6642 | ||
|
|
a802f0bceb | ||
|
|
034318e571 | ||
|
|
75319f20cb | ||
|
|
b348e3c952 | ||
|
|
280a258529 | ||
|
|
8d7541aa7a |
@@ -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
|
||||||
|
|||||||
30
.env.example
30
.env.example
@@ -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=
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
43
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
43
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
34
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
34
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
4095
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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]: {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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];
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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];
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
29
packages/prisma/seed/pr-718-add-stepper-component.ts
Normal file
29
packages/prisma/seed/pr-718-add-stepper-component.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -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[];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 = '**********';
|
|
||||||
@@ -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',
|
||||||
@@ -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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`,
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user