mirror of
https://github.com/documenso/documenso.git
synced 2025-11-11 13:02:31 +10:00
Compare commits
87 Commits
v1.5.3-rc.
...
v1.5.4-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| cbe6270494 | |||
| 81ee582f1c | |||
| 369357aadd | |||
| 117d9427c3 | |||
| 7a689aecae | |||
| 1c54f69a5a | |||
| a56bf6a192 | |||
| a54eb54ef7 | |||
| 956562d3b4 | |||
| f386dd31a7 | |||
| c644d527df | |||
| 47cf20931a | |||
| b491bd4db9 | |||
| 038370012f | |||
| 4d2228f679 | |||
| 0aa111cd6e | |||
| ba30d4368d | |||
| 899205dde8 | |||
| 9eaecfcef2 | |||
| 26141050b7 | |||
| 5b4152ffc5 | |||
| bd703fb620 | |||
| 2296924ef6 | |||
| 6603aa6f2e | |||
| a6ddc114d9 | |||
| abb49c349c | |||
| 006b732edb | |||
| 5210fe2963 | |||
| 994368156f | |||
| 3eddfcc805 | |||
| 43400c07de | |||
| 715c14a6ae | |||
| 606966b357 | |||
| 24852f3c68 | |||
| 48ee977617 | |||
| fc34f1c045 | |||
| 1725af71b6 | |||
| c71347aeb9 | |||
| 007687bdee | |||
| f5a1d9a625 | |||
| 72fd1eead2 | |||
| 5289ae2fbc | |||
| c4c6e67249 | |||
| 5377d27c6a | |||
| 1cd7dd236b | |||
| 67beb8225c | |||
| 94198e7584 | |||
| facafe0997 | |||
| 8c1686f113 | |||
| a8752098f6 | |||
| 3e15b5d745 | |||
| 0dfdf36bda | |||
| 574cd176c2 | |||
| 48858cfdd0 | |||
| 2facc0e331 | |||
| e7071f1f5a | |||
| b95f7176e2 | |||
| 6d754acfcd | |||
| 796456929e | |||
| de9c9f4aab | |||
| b972056c8f | |||
| 69871e7d39 | |||
| 9cfd769356 | |||
| bd20c5499f | |||
| 3c6cc7fd46 | |||
| 8859b2779f | |||
| bba1ea81d6 | |||
| 364aaa4cb6 | |||
| af6ec5df42 | |||
| 35c1b0bcee | |||
| 487bc026f9 | |||
| 3fb57c877e | |||
| 4dc9e1295b | |||
| a8413fa031 | |||
| 3b65447b0f | |||
| d8911ee97b | |||
| c10cfbf6e1 | |||
| 884eab36eb | |||
| d0b9cee500 | |||
| 62b4a13d4d | |||
| 19714fb807 | |||
| 7631c6e90e | |||
| d462ca0b46 | |||
| 0d41c6babf | |||
| 9b5346efef | |||
| c463d5a0ed | |||
| 8afe669978 |
@ -1,13 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# Start the database and mailserver
|
|
||||||
docker compose -f ./docker/compose-without-app.yml up -d
|
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Copy the env file
|
# Copy the env file
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
# Run the migrations
|
# Run the dev setup
|
||||||
npm run prisma:migrate-dev
|
npm run dx
|
||||||
|
|||||||
30
.env.example
30
.env.example
@ -22,10 +22,23 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen
|
|||||||
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
# Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool.
|
||||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||||
|
|
||||||
# [[E2E Tests]]
|
# [[SIGNING]]
|
||||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
# The transport to use for document signing. Available options: local (default) | gcloud-hsm
|
||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
|
||||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
# OPTIONAL: The passphrase to use for the local file-based signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_PASSPHRASE=
|
||||||
|
# OPTIONAL: The local file path to the .p12 file to use for the local signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH=
|
||||||
|
# OPTIONAL: The base64-encoded contents of the .p12 file to use for the local signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS=
|
||||||
|
# OPTIONAL: The path to the Google Cloud HSM key to use for the gcloud-hsm signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH=
|
||||||
|
# OPTIONAL: The path to the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH=
|
||||||
|
# OPTIONAL: The base64-encoded contents of the Google Cloud HSM public certificate file to use for the gcloud-hsm signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS=
|
||||||
|
# OPTIONAL: The path to the Google Cloud Credentials file to use for the gcloud-hsm signing transport.
|
||||||
|
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS=
|
||||||
|
|
||||||
# [[SIGNING]]
|
# [[SIGNING]]
|
||||||
# OPTIONAL: Defines the signing transport to use. Available options: local (default)
|
# OPTIONAL: Defines the signing transport to use. Available options: local (default)
|
||||||
@ -42,6 +55,9 @@ 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.
|
||||||
@ -91,6 +107,7 @@ 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.
|
||||||
@ -100,6 +117,11 @@ NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
|
|||||||
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
# OPTIONAL: Leave blank to allow users to signup through /signup page.
|
||||||
NEXT_PUBLIC_DISABLE_SIGNUP=
|
NEXT_PUBLIC_DISABLE_SIGNUP=
|
||||||
|
|
||||||
|
# [[E2E Tests]]
|
||||||
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
|
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||||
|
|
||||||
# This is only required for the marketing site
|
# This is only required for the marketing site
|
||||||
# [[REDIS]]
|
# [[REDIS]]
|
||||||
NEXT_PRIVATE_REDIS_URL=
|
NEXT_PRIVATE_REDIS_URL=
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.eslint": "explicit"
|
"source.fixAll": "explicit"
|
||||||
},
|
},
|
||||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
|||||||
@ -21,7 +21,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
|||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverComponentsExternalPackages: ['@node-rs/bcrypt'],
|
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -38,7 +38,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
|
className={cn('relative flex min-h-[100vh] max-w-[100vw] flex-col pt-20 md:pt-28', {
|
||||||
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
|
'overflow-y-auto overflow-x-hidden':
|
||||||
|
pathname && !['/singleplayer', '/pricing'].includes(pathname),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & {
|
export type BarMetricProps<T extends Record<string, unknown>> = HTMLAttributes<HTMLDivElement> & {
|
||||||
data: T;
|
data: T;
|
||||||
@ -34,13 +33,13 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
|||||||
.reverse();
|
.reverse();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={className} {...props}>
|
||||||
<div className="flex items-center px-4">
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
<span>{extraInfo}</span>
|
<span>{extraInfo}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
<BarChart data={formattedData}>
|
<BarChart data={formattedData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
|
|||||||
@ -5,8 +5,6 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
|
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
import { CAP_TABLE } from './data';
|
import { CAP_TABLE } from './data';
|
||||||
|
|
||||||
const COLORS = ['#7fd843', '#a2e771', '#c6f2a4'];
|
const COLORS = ['#7fd843', '#a2e771', '#c6f2a4'];
|
||||||
@ -49,10 +47,12 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
|
|||||||
setIsSSR(false);
|
setIsSSR(false);
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={className} {...props}>
|
||||||
<h3 className="px-4 text-lg font-semibold">Cap Table</h3>
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Cap Table</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border shadow-sm hover:shadow">
|
|
||||||
{!isSSR && (
|
{!isSSR && (
|
||||||
<PieChart width={400} height={400}>
|
<PieChart width={400} height={400}>
|
||||||
<Pie
|
<Pie
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
data: Record<string, string | number>[];
|
data: Record<string, string | number>[];
|
||||||
@ -18,10 +17,12 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={className} {...props}>
|
||||||
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Total Funding Raised</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
|
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" />
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
|
||||||
|
export type MonthlyCompletedDocumentsChartProps = {
|
||||||
|
className?: string;
|
||||||
|
data: GetUserMonthlyGrowthResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MonthlyCompletedDocumentsChart = ({
|
||||||
|
className,
|
||||||
|
data,
|
||||||
|
}: MonthlyCompletedDocumentsChartProps) => {
|
||||||
|
const formattedData = [...data].reverse().map(({ month, count }) => {
|
||||||
|
return {
|
||||||
|
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
|
||||||
|
count: Number(count),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Completed Documents per Month</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={formattedData}>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
labelStyle={{
|
||||||
|
color: 'hsl(var(--primary-foreground))',
|
||||||
|
}}
|
||||||
|
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Completed Documents']}
|
||||||
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={60}
|
||||||
|
label="Completed Documents"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
|
|||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type MonthlyNewUsersChartProps = {
|
export type MonthlyNewUsersChartProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -20,12 +19,12 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={className}>
|
||||||
<div className="flex items-center px-4">
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
<h3 className="text-lg font-semibold">New Users</h3>
|
<h3 className="text-lg font-semibold">New Users</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={formattedData}>
|
<BarChart data={formattedData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { DateTime } from 'luxon';
|
|||||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type MonthlyTotalUsersChartProps = {
|
export type MonthlyTotalUsersChartProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -20,12 +19,12 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)}>
|
<div className={className}>
|
||||||
<div className="flex items-center px-4">
|
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
<h3 className="text-lg font-semibold">Total Users</h3>
|
<h3 className="text-lg font-semibold">Total Users</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={formattedData}>
|
<BarChart data={formattedData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
|
|||||||
@ -2,20 +2,23 @@ import type { Metadata } from 'next';
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getCompletedDocumentsMonthly } from '@documenso/lib/server-only/user/get-monthly-completed-document';
|
||||||
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
|
||||||
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
|
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
|
||||||
import { MetricCard } from '~/app/(marketing)/open/metric-card';
|
|
||||||
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
|
|
||||||
import { CallToAction } from '~/components/(marketing)/call-to-action';
|
import { CallToAction } from '~/components/(marketing)/call-to-action';
|
||||||
|
|
||||||
import { BarMetric } from './bar-metrics';
|
import { BarMetric } from './bar-metrics';
|
||||||
import { CapTable } from './cap-table';
|
import { CapTable } from './cap-table';
|
||||||
import { FundingRaised } from './funding-raised';
|
import { FundingRaised } from './funding-raised';
|
||||||
|
import { MetricCard } from './metric-card';
|
||||||
|
import { MonthlyCompletedDocumentsChart } from './monthly-completed-documents-chart';
|
||||||
import { MonthlyNewUsersChart } from './monthly-new-users-chart';
|
import { MonthlyNewUsersChart } from './monthly-new-users-chart';
|
||||||
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
|
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
|
||||||
|
import { SalaryBands } from './salary-bands';
|
||||||
import { TeamMembers } from './team-members';
|
import { TeamMembers } from './team-members';
|
||||||
import { OpenPageTooltip } from './tooltip';
|
import { OpenPageTooltip } from './tooltip';
|
||||||
|
import { TotalSignedDocumentsChart } from './total-signed-documents-chart';
|
||||||
import { Typefully } from './typefully';
|
import { Typefully } from './typefully';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -131,16 +134,18 @@ export default async function OpenPage() {
|
|||||||
{ total_count: mergedPullRequests },
|
{ total_count: mergedPullRequests },
|
||||||
STARGAZERS_DATA,
|
STARGAZERS_DATA,
|
||||||
EARLY_ADOPTERS_DATA,
|
EARLY_ADOPTERS_DATA,
|
||||||
|
MONTHLY_USERS,
|
||||||
|
MONTHLY_COMPLETED_DOCUMENTS,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchGithubStats(),
|
fetchGithubStats(),
|
||||||
fetchOpenIssues(),
|
fetchOpenIssues(),
|
||||||
fetchMergedPullRequests(),
|
fetchMergedPullRequests(),
|
||||||
fetchStargazers(),
|
fetchStargazers(),
|
||||||
fetchEarlyAdopters(),
|
fetchEarlyAdopters(),
|
||||||
|
getUserMonthlyGrowth(),
|
||||||
|
getCompletedDocumentsMonthly(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MONTHLY_USERS = await getUserMonthlyGrowth();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
||||||
@ -161,7 +166,7 @@ export default async function OpenPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 grid grid-cols-12 gap-8">
|
<div className="my-12 grid grid-cols-12 gap-8">
|
||||||
<div className="col-span-12 grid grid-cols-4 gap-4">
|
<div className="col-span-12 grid grid-cols-4 gap-4">
|
||||||
<MetricCard
|
<MetricCard
|
||||||
className="col-span-2 lg:col-span-1"
|
className="col-span-2 lg:col-span-1"
|
||||||
@ -188,11 +193,57 @@ export default async function OpenPage() {
|
|||||||
<TeamMembers className="col-span-12" />
|
<TeamMembers className="col-span-12" />
|
||||||
|
|
||||||
<SalaryBands className="col-span-12" />
|
<SalaryBands className="col-span-12" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="px-4 text-2xl font-semibold">Finances</h2>
|
||||||
|
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
|
||||||
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
|
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
|
||||||
|
|
||||||
<CapTable className="col-span-12 lg:col-span-6" />
|
<CapTable className="col-span-12 lg:col-span-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="px-4 text-2xl font-semibold">Community</h2>
|
||||||
|
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
|
||||||
|
<BarMetric<StargazersType>
|
||||||
|
data={STARGAZERS_DATA}
|
||||||
|
metricKey="stars"
|
||||||
|
title="GitHub: Total Stars"
|
||||||
|
label="Stars"
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
|
data={STARGAZERS_DATA}
|
||||||
|
metricKey="mergedPRs"
|
||||||
|
title="GitHub: Total Merged PRs"
|
||||||
|
label="Merged PRs"
|
||||||
|
chartHeight={400}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
|
data={STARGAZERS_DATA}
|
||||||
|
metricKey="forks"
|
||||||
|
title="GitHub: Total Forks"
|
||||||
|
label="Forks"
|
||||||
|
chartHeight={400}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BarMetric<StargazersType>
|
||||||
|
data={STARGAZERS_DATA}
|
||||||
|
metricKey="openIssues"
|
||||||
|
title="GitHub: Total Open Issues"
|
||||||
|
label="Open Issues"
|
||||||
|
chartHeight={400}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typefully className="col-span-12 lg:col-span-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="px-4 text-2xl font-semibold">Growth</h2>
|
||||||
|
<div className="mb-12 mt-4 grid grid-cols-12 gap-8">
|
||||||
<BarMetric<EarlyAdoptersType>
|
<BarMetric<EarlyAdoptersType>
|
||||||
data={EARLY_ADOPTERS_DATA}
|
data={EARLY_ADOPTERS_DATA}
|
||||||
metricKey="earlyAdopters"
|
metricKey="earlyAdopters"
|
||||||
@ -202,56 +253,28 @@ export default async function OpenPage() {
|
|||||||
extraInfo={<OpenPageTooltip />}
|
extraInfo={<OpenPageTooltip />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BarMetric<StargazersType>
|
|
||||||
data={STARGAZERS_DATA}
|
|
||||||
metricKey="stars"
|
|
||||||
title="Github: Total Stars"
|
|
||||||
label="Stars"
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BarMetric<StargazersType>
|
|
||||||
data={STARGAZERS_DATA}
|
|
||||||
metricKey="mergedPRs"
|
|
||||||
title="Github: Total Merged PRs"
|
|
||||||
label="Merged PRs"
|
|
||||||
chartHeight={300}
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BarMetric<StargazersType>
|
|
||||||
data={STARGAZERS_DATA}
|
|
||||||
metricKey="forks"
|
|
||||||
title="Github: Total Forks"
|
|
||||||
label="Forks"
|
|
||||||
chartHeight={300}
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BarMetric<StargazersType>
|
|
||||||
data={STARGAZERS_DATA}
|
|
||||||
metricKey="openIssues"
|
|
||||||
title="Github: Total Open Issues"
|
|
||||||
label="Open Issues"
|
|
||||||
chartHeight={300}
|
|
||||||
className="col-span-12 lg:col-span-6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
<MonthlyTotalUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
||||||
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
||||||
|
|
||||||
<Typefully className="col-span-12 lg:col-span-6" />
|
<MonthlyCompletedDocumentsChart
|
||||||
|
data={MONTHLY_COMPLETED_DOCUMENTS}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
<TotalSignedDocumentsChart
|
||||||
|
data={MONTHLY_COMPLETED_DOCUMENTS}
|
||||||
|
className="col-span-12 lg:col-span-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
||||||
<h2 className="text-2xl font-bold">Where's the rest?</h2>
|
<h2 className="text-2xl font-bold">Is there more?</h2>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
|
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
|
||||||
We're still working on getting all our metrics together. We'll update this page as
|
This page is evolving as we learn what makes a great signing company. We'll update it when
|
||||||
soon as we have more to share.
|
we have more to share.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CallToAction className="mt-12" utmSource="open-page" />
|
<CallToAction className="mt-12" utmSource="open-page" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
|
||||||
|
export type TotalSignedDocumentsChartProps = {
|
||||||
|
className?: string;
|
||||||
|
data: GetUserMonthlyGrowthResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TotalSignedDocumentsChart = ({ className, data }: TotalSignedDocumentsChartProps) => {
|
||||||
|
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
|
||||||
|
return {
|
||||||
|
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLLL'),
|
||||||
|
count: Number(count),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Total Completed Documents</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={formattedData}>
|
||||||
|
<XAxis dataKey="month" />
|
||||||
|
<YAxis />
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
labelStyle={{
|
||||||
|
color: 'hsl(var(--primary-foreground))',
|
||||||
|
}}
|
||||||
|
formatter={(value) => [
|
||||||
|
Number(value).toLocaleString('en-US'),
|
||||||
|
'Total Completed Documents',
|
||||||
|
]}
|
||||||
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={60}
|
||||||
|
label="Total Completed Documents"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,18 +6,19 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import { FaXTwitter } from 'react-icons/fa6';
|
import { FaXTwitter } from 'react-icons/fa6';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
export type TypefullyProps = HTMLAttributes<HTMLDivElement>;
|
export type TypefullyProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const Typefully = ({ className, ...props }: TypefullyProps) => {
|
export const Typefully = ({ className, ...props }: TypefullyProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={className} {...props}>
|
||||||
<h3 className="px-4 text-lg font-semibold">Twitter Stats</h3>
|
<div className="border-border flex flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||||
|
<div className="mb-6 flex px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Twitter Stats</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border py-8 shadow-sm hover:shadow">
|
<div className="my-12 flex flex-col items-center gap-y-4 text-center">
|
||||||
<div className="flex flex-col items-center gap-y-4 text-center">
|
|
||||||
<FaXTwitter className="h-12 w-12" />
|
<FaXTwitter className="h-12 w-12" />
|
||||||
<Link href="https://typefully.com/documenso/stats" target="_blank">
|
<Link href="https://typefully.com/documenso/stats" target="_blank">
|
||||||
<h1>Documenso on X</h1>
|
<h1>Documenso on X</h1>
|
||||||
|
|||||||
@ -161,6 +161,7 @@ export const SinglePlayerClient = () => {
|
|||||||
signingStatus: 'NOT_SIGNED',
|
signingStatus: 'NOT_SIGNED',
|
||||||
sendStatus: 'NOT_SENT',
|
sendStatus: 'NOT_SENT',
|
||||||
role: 'SIGNER',
|
role: 'SIGNER',
|
||||||
|
authOptions: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
@ -246,6 +247,7 @@ 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="flex items-center justify-center gap-x-6">
|
<div className="bg-background sticky top-32 flex items-center justify-end gap-x-6 shadow-[-1px_-5px_2px_6px_hsl(var(--background))] md:top-[7.5rem] lg:static lg:justify-center">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
<motion.button
|
<motion.button
|
||||||
key="MONTHLY"
|
key="MONTHLY"
|
||||||
@ -40,7 +40,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
{period === 'MONTHLY' && (
|
{period === 'MONTHLY' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
||||||
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@ -63,7 +63,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
|||||||
{period === 'YEARLY' && (
|
{period === 'YEARLY' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
layoutId={SELECTED_PLAN_BAR_LAYOUT_ID}
|
||||||
className="bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
className="bg-foreground lg:bg-primary absolute bottom-0 left-0 h-[3px] w-full rounded-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ const config = {
|
|||||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
experimental: {
|
experimental: {
|
||||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||||
serverComponentsExternalPackages: ['@node-rs/bcrypt'],
|
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: '50mb',
|
bodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -22,7 +22,10 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@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",
|
||||||
@ -51,6 +54,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
"@simplewebauthn/types": "^9.0.1",
|
||||||
"@types/formidable": "^2.0.6",
|
"@types/formidable": "^2.0.6",
|
||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
|||||||
|
|
||||||
import { AdminActions } from './admin-actions';
|
import { AdminActions } from './admin-actions';
|
||||||
import { RecipientItem } from './recipient-item';
|
import { RecipientItem } from './recipient-item';
|
||||||
|
import { SuperDeleteDocumentDialog } from './super-delete-document-dialog';
|
||||||
|
|
||||||
type AdminDocumentDetailsPageProps = {
|
type AdminDocumentDetailsPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -81,6 +82,10 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
|||||||
))}
|
))}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
{document && <SuperDeleteDocumentDialog document={document} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { Document } from '@documenso/prisma/client';
|
||||||
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type SuperDeleteDocumentDialogProps = {
|
||||||
|
document: Document;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SuperDeleteDocumentDialog = ({ document }: SuperDeleteDocumentDialogProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
const { mutateAsync: deleteDocument, isLoading: isDeletingDocument } =
|
||||||
|
trpc.admin.deleteDocument.useMutation();
|
||||||
|
|
||||||
|
const handleDeleteDocument = async () => {
|
||||||
|
try {
|
||||||
|
if (!reason) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteDocument({ id: document.id, reason });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document deleted',
|
||||||
|
description: 'The Document has been deleted successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/admin/documents');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||||
|
toast({
|
||||||
|
title: 'An error occurred',
|
||||||
|
description: err.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
description:
|
||||||
|
err.message ??
|
||||||
|
'We encountered an unknown error while attempting to delete your document. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Alert
|
||||||
|
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row "
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<AlertTitle>Delete Document</AlertTitle>
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
Delete the document. This action is irreversible so proceed with caution.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive">Delete Document</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader className="space-y-4">
|
||||||
|
<DialogTitle>Delete Document</DialogTitle>
|
||||||
|
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription className="selection:bg-red-100">
|
||||||
|
This action is not reversible. Please be certain.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<DialogDescription>To confirm, please enter the reason</DialogDescription>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="mt-2"
|
||||||
|
type="text"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleDeleteDocument}
|
||||||
|
loading={isDeletingDocument}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={!reason}
|
||||||
|
>
|
||||||
|
{isDeletingDocument ? 'Deleting document...' : 'Delete Document'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -58,6 +58,7 @@ export const UsersDataTable = ({
|
|||||||
perPage,
|
perPage,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedSearchString]);
|
}, [debouncedSearchString]);
|
||||||
|
|
||||||
const onPaginationChange = (page: number, perPage: number) => {
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
|||||||
@ -1,29 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type DocumentData,
|
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
type DocumentMeta,
|
SKIP_QUERY_BATCH_META,
|
||||||
DocumentStatus,
|
} 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';
|
||||||
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
|
||||||
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
import { AddSettingsFormPartial } from '@documenso/ui/primitives/document-flow/add-settings';
|
||||||
|
import type { TAddSettingsFormSchema } from '@documenso/ui/primitives/document-flow/add-settings.types';
|
||||||
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/add-signers';
|
||||||
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
|
||||||
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
import { AddTitleFormPartial } from '@documenso/ui/primitives/document-flow/add-title';
|
|
||||||
import type { TAddTitleFormSchema } from '@documenso/ui/primitives/document-flow/add-title.types';
|
|
||||||
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
@ -34,27 +30,19 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
|||||||
|
|
||||||
export type EditDocumentFormProps = {
|
export type EditDocumentFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
user: User;
|
initialDocument: DocumentWithDetails;
|
||||||
document: DocumentWithData;
|
|
||||||
recipients: Recipient[];
|
|
||||||
documentMeta: DocumentMeta | null;
|
|
||||||
fields: Field[];
|
|
||||||
documentData: DocumentData;
|
|
||||||
documentRootPath: string;
|
documentRootPath: string;
|
||||||
|
isDocumentEnterprise: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EditDocumentStep = 'title' | 'signers' | 'fields' | 'subject';
|
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
|
||||||
const EditDocumentSteps: EditDocumentStep[] = ['title', 'signers', 'fields', 'subject'];
|
const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields', 'subject'];
|
||||||
|
|
||||||
export const EditDocumentForm = ({
|
export const EditDocumentForm = ({
|
||||||
className,
|
className,
|
||||||
document,
|
initialDocument,
|
||||||
recipients,
|
|
||||||
fields,
|
|
||||||
documentMeta,
|
|
||||||
user: _user,
|
|
||||||
documentData,
|
|
||||||
documentRootPath,
|
documentRootPath,
|
||||||
|
isDocumentEnterprise,
|
||||||
}: EditDocumentFormProps) => {
|
}: EditDocumentFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -62,17 +50,83 @@ export const EditDocumentForm = ({
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const team = useOptionalCurrentTeam();
|
const team = useOptionalCurrentTeam();
|
||||||
|
|
||||||
const { mutateAsync: addTitle } = trpc.document.setTitleForDocument.useMutation();
|
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
|
||||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
const utils = trpc.useUtils();
|
||||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
|
||||||
|
const { data: document, refetch: refetchDocument } =
|
||||||
|
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();
|
||||||
|
|
||||||
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
|
||||||
title: {
|
settings: {
|
||||||
title: 'Add Title',
|
title: 'General',
|
||||||
description: 'Add the title to the document.',
|
description: 'Configure general settings for the document.',
|
||||||
stepIndex: 1,
|
stepIndex: 1,
|
||||||
},
|
},
|
||||||
signers: {
|
signers: {
|
||||||
@ -96,8 +150,7 @@ export const EditDocumentForm = ({
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
|
const searchParamStep = searchParams?.get('step') as EditDocumentStep | undefined;
|
||||||
|
|
||||||
let initialStep: EditDocumentStep =
|
let initialStep: EditDocumentStep = 'settings';
|
||||||
document.status === DocumentStatus.DRAFT ? 'title' : 'signers';
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
searchParamStep &&
|
searchParamStep &&
|
||||||
@ -110,15 +163,26 @@ export const EditDocumentForm = ({
|
|||||||
return initialStep;
|
return initialStep;
|
||||||
});
|
});
|
||||||
|
|
||||||
const onAddTitleFormSubmit = async (data: TAddTitleFormSchema) => {
|
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
|
||||||
try {
|
try {
|
||||||
// Custom invocation server action
|
const { timezone, dateFormat, redirectUrl } = data.meta;
|
||||||
await addTitle({
|
|
||||||
|
await setSettingsForDocument({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
|
globalAccessAuth: data.globalAccessAuth ?? null,
|
||||||
|
globalActionAuth: data.globalActionAuth ?? null,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
timezone,
|
||||||
|
dateFormat,
|
||||||
|
redirectUrl,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
setStep('signers');
|
setStep('signers');
|
||||||
@ -127,7 +191,7 @@ export const EditDocumentForm = ({
|
|||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
description: 'An error occurred while updating title.',
|
description: 'An error occurred while updating the document settings.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -135,14 +199,19 @@ 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,
|
||||||
signers: data.signers,
|
signers: data.signers.map((signer) => ({
|
||||||
|
...signer,
|
||||||
|
// Explicitly set to null to indicate we want to remove auth if required.
|
||||||
|
actionAuth: signer.actionAuth || null,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
@ -157,13 +226,14 @@ 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);
|
||||||
@ -177,7 +247,7 @@ export const EditDocumentForm = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||||
const { subject, message, timezone, dateFormat, redirectUrl } = data.meta;
|
const { subject, message } = data.meta;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendDocument({
|
await sendDocument({
|
||||||
@ -186,9 +256,6 @@ export const EditDocumentForm = ({
|
|||||||
meta: {
|
meta: {
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
dateFormat,
|
|
||||||
timezone,
|
|
||||||
redirectUrl,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -219,6 +286,15 @@ 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
|
||||||
@ -227,11 +303,12 @@ export const EditDocumentForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<LazyPDFViewer
|
<LazyPDFViewer
|
||||||
key={documentData.id}
|
key={document.documentData.id}
|
||||||
documentData={documentData}
|
documentData={document.documentData}
|
||||||
document={document}
|
document={document}
|
||||||
password={documentMeta?.password}
|
password={document.documentMeta?.password}
|
||||||
onPasswordSubmit={onPasswordSubmit}
|
onPasswordSubmit={onPasswordSubmit}
|
||||||
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -245,30 +322,35 @@ export const EditDocumentForm = ({
|
|||||||
currentStep={currentDocumentFlow.stepIndex}
|
currentStep={currentDocumentFlow.stepIndex}
|
||||||
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
setCurrentStep={(step) => setStep(EditDocumentSteps[step - 1])}
|
||||||
>
|
>
|
||||||
<AddTitleFormPartial
|
<AddSettingsFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.title}
|
documentFlow={documentFlow.settings}
|
||||||
document={document}
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddTitleFormSubmit}
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
|
onSubmit={onAddSettingsFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSignersFormPartial
|
<AddSignersFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.signers}
|
documentFlow={documentFlow.signers}
|
||||||
document={document}
|
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
onSubmit={onAddSignersFormSubmit}
|
onSubmit={onAddSignersFormSubmit}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddFieldsFormPartial
|
<AddFieldsFormPartial
|
||||||
key={fields.length}
|
key={fields.length}
|
||||||
documentFlow={documentFlow.fields}
|
documentFlow={documentFlow.fields}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddFieldsFormSubmit}
|
onSubmit={onAddFieldsFormSubmit}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddSubjectFormPartial
|
<AddSubjectFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.subject}
|
documentFlow={documentFlow.subject}
|
||||||
@ -276,6 +358,7 @@ export const EditDocumentForm = ({
|
|||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onAddSubjectFormSubmit}
|
onSubmit={onAddSubjectFormSubmit}
|
||||||
|
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</DocumentFlowFormContainer>
|
</DocumentFlowFormContainer>
|
||||||
|
|||||||
@ -3,11 +3,10 @@ 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 { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-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';
|
||||||
@ -37,13 +36,18 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const document = await getDocumentById({
|
const isDocumentEnterprise = await isUserEnterprise({
|
||||||
|
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 || !document.documentData) {
|
if (!document) {
|
||||||
redirect(documentRootPath);
|
redirect(documentRootPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,7 +55,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
redirect(`${documentRootPath}/${documentId}`);
|
redirect(`${documentRootPath}/${documentId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const { documentMeta, Recipient: recipients } = document;
|
||||||
|
|
||||||
if (documentMeta?.password) {
|
if (documentMeta?.password) {
|
||||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
@ -70,18 +74,6 @@ 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">
|
||||||
@ -109,13 +101,9 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
|||||||
|
|
||||||
<EditDocumentForm
|
<EditDocumentForm
|
||||||
className="mt-8"
|
className="mt-8"
|
||||||
document={document}
|
initialDocument={document}
|
||||||
user={user}
|
|
||||||
documentMeta={documentMeta}
|
|
||||||
recipients={recipients}
|
|
||||||
fields={fields}
|
|
||||||
documentData={documentData}
|
|
||||||
documentRootPath={documentRootPath}
|
documentRootPath={documentRootPath}
|
||||||
|
isDocumentEnterprise={isDocumentEnterprise}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -15,15 +15,14 @@ export default function SettingsSecurityActivityPage() {
|
|||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title="Security activity"
|
title="Security activity"
|
||||||
subtitle="View all recent security activity related to your account."
|
subtitle="View all recent security activity related to your account."
|
||||||
|
hideDivider={true}
|
||||||
>
|
>
|
||||||
<div>
|
|
||||||
<ActivityPageBackButton />
|
<ActivityPageBackButton />
|
||||||
</div>
|
|
||||||
</SettingsHeader>
|
</SettingsHeader>
|
||||||
|
|
||||||
<hr className="my-4" />
|
<div className="mt-4">
|
||||||
|
|
||||||
<UserSecurityActivityDataTable />
|
<UserSecurityActivityDataTable />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +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 { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
|
||||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
|
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -18,6 +19,8 @@ export const metadata: Metadata = {
|
|||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
@ -25,11 +28,13 @@ export default async function SecuritySettingsPage() {
|
|||||||
subtitle="Here you can manage your password and security settings."
|
subtitle="Here you can manage your password and security settings."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user.identityProvider === 'DOCUMENSO' ? (
|
{user.identityProvider === 'DOCUMENSO' && (
|
||||||
<div>
|
<>
|
||||||
<PasswordForm user={user} />
|
<PasswordForm user={user} />
|
||||||
|
|
||||||
<hr className="border-border/50 mt-6" />
|
<hr className="border-border/50 mt-6" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
@ -39,12 +44,18 @@ export default async function SecuritySettingsPage() {
|
|||||||
<AlertTitle>Two factor authentication</AlertTitle>
|
<AlertTitle>Two factor authentication</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
Create one-time passwords that serve as a secondary authentication method for
|
Add an authenticator to serve as a secondary authentication method{' '}
|
||||||
confirming your identity when requested during the sign-in process.
|
{user.identityProvider === 'DOCUMENSO'
|
||||||
|
? 'when signing in, or when signing documents.'
|
||||||
|
: 'for signing documents.'}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
{user.twoFactorEnabled ? (
|
||||||
|
<DisableAuthenticatorAppDialog />
|
||||||
|
) : (
|
||||||
|
<EnableAuthenticatorAppDialog />
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{user.twoFactorEnabled && (
|
{user.twoFactorEnabled && (
|
||||||
@ -56,26 +67,31 @@ export default async function SecuritySettingsPage() {
|
|||||||
<AlertTitle>Recovery codes</AlertTitle>
|
<AlertTitle>Recovery codes</AlertTitle>
|
||||||
|
|
||||||
<AlertDescription className="mr-4">
|
<AlertDescription className="mr-4">
|
||||||
Two factor authentication recovery codes are used to access your account in the
|
Two factor authentication recovery codes are used to access your account in the event
|
||||||
event that you lose access to your authenticator app.
|
that you lose access to your authenticator app.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
<ViewRecoveryCodesDialog />
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Alert className="p-6" variant="neutral">
|
|
||||||
<AlertTitle>
|
|
||||||
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
|
||||||
</AlertTitle>
|
|
||||||
|
|
||||||
<AlertDescription>
|
{isPasskeyEnabled && (
|
||||||
To update your password, enable two-factor authentication, and manage other security
|
<Alert
|
||||||
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
settings.
|
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>
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="outline" className="bg-background">
|
||||||
|
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
||||||
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -91,7 +107,7 @@ export default async function SecuritySettingsPage() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild>
|
<Button asChild variant="outline" className="bg-background">
|
||||||
<Link href="/settings/security/activity">View activity</Link>
|
<Link href="/settings/security/activity">View activity</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type CreatePasskeyDialogProps = {
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||||
|
|
||||||
|
const ZCreatePasskeyFormSchema = z.object({
|
||||||
|
passkeyName: z.string().min(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||||
|
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<TCreatePasskeyFormSchema>({
|
||||||
|
resolver: zodResolver(ZCreatePasskeyFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
passkeyName: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeyRegistrationOptions, isLoading } =
|
||||||
|
trpc.auth.createPasskeyRegistrationOptions.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskey } = trpc.auth.createPasskey.useMutation();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ passkeyName }: TCreatePasskeyFormSchema) => {
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const passkeyRegistrationOptions = await createPasskeyRegistrationOptions();
|
||||||
|
|
||||||
|
const registrationResult = await startRegistration(passkeyRegistrationOptions);
|
||||||
|
|
||||||
|
await createPasskey({
|
||||||
|
passkeyName,
|
||||||
|
verificationResponse: registrationResult,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: 'Successfully created passkey',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
setOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
setFormError(err.code || error.code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractDefaultPasskeyName = () => {
|
||||||
|
if (!window || !window.navigator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(window.navigator.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
const operatingSystem = result.os.name;
|
||||||
|
const browser = result.browser.name;
|
||||||
|
|
||||||
|
let passkeyName = '';
|
||||||
|
|
||||||
|
if (operatingSystem && browser) {
|
||||||
|
passkeyName = `${browser} (${operatingSystem})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return passkeyName;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
const defaultPasskeyName = extractDefaultPasskeyName();
|
||||||
|
|
||||||
|
form.reset({
|
||||||
|
passkeyName: defaultPasskeyName,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
{...props}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
{trigger ?? (
|
||||||
|
<Button variant="secondary" loading={isLoading}>
|
||||||
|
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
|
||||||
|
Add passkey
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="passkeyName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Passkey name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input className="bg-background" placeholder="eg. Mac" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert variant="neutral">
|
||||||
|
<AlertDescription>
|
||||||
|
When you click continue, you will be prompted to add the first available
|
||||||
|
authenticator on your system.
|
||||||
|
</AlertDescription>
|
||||||
|
|
||||||
|
<AlertDescription className="mt-2">
|
||||||
|
If you do not want to use the authenticator prompted, you can close it, which will
|
||||||
|
then display the next available authenticator.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
{match(formError)
|
||||||
|
.with('ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED', () => (
|
||||||
|
<AlertDescription>This passkey has already been registered.</AlertDescription>
|
||||||
|
))
|
||||||
|
.with('TOO_MANY_PASSKEYS', () => (
|
||||||
|
<AlertDescription>
|
||||||
|
You cannot have more than {MAXIMUM_PASSKEYS} passkeys.
|
||||||
|
</AlertDescription>
|
||||||
|
))
|
||||||
|
.with('InvalidStateError', () => (
|
||||||
|
<>
|
||||||
|
<AlertTitle className="text-sm">
|
||||||
|
Passkey creation cancelled due to one of the following reasons:
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ul className="mt-1 list-inside list-disc">
|
||||||
|
<li>Cancelled by user</li>
|
||||||
|
<li>Passkey already exists for the provided authenticator</li>
|
||||||
|
<li>Exceeded timeout</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<AlertDescription>
|
||||||
|
Something went wrong. Please try again or contact support.
|
||||||
|
</AlertDescription>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
|
|
||||||
|
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||||
|
|
||||||
|
import { CreatePasskeyDialog } from './create-passkey-dialog';
|
||||||
|
import { UserPasskeysDataTable } from './user-passkeys-data-table';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Manage passkeys',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function SettingsManagePasskeysPage() {
|
||||||
|
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');
|
||||||
|
|
||||||
|
if (!isPasskeyEnabled) {
|
||||||
|
redirect('/settings/security');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SettingsHeader title="Passkeys" subtitle="Manage your passkeys." hideDivider={true}>
|
||||||
|
<CreatePasskeyDialog />
|
||||||
|
</SettingsHeader>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<UserPasskeysDataTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,200 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type UserPasskeysDataTableActionsProps = {
|
||||||
|
className?: string;
|
||||||
|
passkeyId: string;
|
||||||
|
passkeyName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZUpdatePasskeySchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TUpdatePasskeySchema = z.infer<typeof ZUpdatePasskeySchema>;
|
||||||
|
|
||||||
|
export const UserPasskeysDataTableActions = ({
|
||||||
|
className,
|
||||||
|
passkeyId,
|
||||||
|
passkeyName,
|
||||||
|
}: UserPasskeysDataTableActionsProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
|
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<TUpdatePasskeySchema>({
|
||||||
|
resolver: zodResolver(ZUpdatePasskeySchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: passkeyName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: updatePasskey, isLoading: isUpdatingPasskey } =
|
||||||
|
trpc.auth.updatePasskey.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Passkey has been updated',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We are unable to update this passkey at the moment. Please try again later.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deletePasskey, isLoading: isDeletingPasskey } =
|
||||||
|
trpc.auth.deletePasskey.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Passkey has been removed',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description:
|
||||||
|
'We are unable to remove this passkey at the moment. Please try again later.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex justify-end space-x-2', className)}>
|
||||||
|
<Dialog
|
||||||
|
open={isUpdateDialogOpen}
|
||||||
|
onOpenChange={(value) => !isUpdatingPasskey && setIsUpdateDialogOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||||
|
<Button variant="outline">Edit</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Update passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
You are currently updating the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(async ({ name }) =>
|
||||||
|
updatePasskey({
|
||||||
|
passkeyId,
|
||||||
|
name,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<fieldset className="flex h-full flex-col" disabled={isUpdatingPasskey}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel required>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isUpdatingPasskey}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(value) => !isDeletingPasskey && setIsDeleteDialogOpen(value)}
|
||||||
|
>
|
||||||
|
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||||
|
<Button variant="destructive">Delete</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete passkey</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription className="mt-4">
|
||||||
|
Are you sure you want to remove the <strong>{passkeyName}</strong> passkey.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<fieldset disabled={isDeletingPasskey}>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={async () =>
|
||||||
|
deletePasskey({
|
||||||
|
passkeyId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDeletingPasskey}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { UserPasskeysDataTableActions } from './user-passkeys-data-table-actions';
|
||||||
|
|
||||||
|
export const UserPasskeysDataTable = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } = trpc.auth.findPasskeys.useQuery(
|
||||||
|
{
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Created',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => DateTime.fromJSDate(row.original.createdAt).toRelative(),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
header: 'Last used',
|
||||||
|
accessorKey: 'updatedAt',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.lastUsedAt
|
||||||
|
? DateTime.fromJSDate(row.original.lastUsedAt).toRelative()
|
||||||
|
: 'Never',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<UserPasskeysDataTableActions
|
||||||
|
className="justify-end"
|
||||||
|
passkeyId={row.original.id}
|
||||||
|
passkeyName={row.original.name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||||
|
onClearFilters={() => router.push(pathname ?? '/')}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-row space-x-2">
|
||||||
|
<Skeleton className="h-8 w-16 rounded" />
|
||||||
|
<Skeleton className="h-8 w-16 rounded" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,7 +6,9 @@ import { getServerSession } from 'next-auth';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
|
||||||
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
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 { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
@ -17,6 +19,7 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
|||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { SigningAuthPageView } from '../signing-auth-page';
|
||||||
import { DocumentPreviewButton } from './document-preview-button';
|
import { DocumentPreviewButton } from './document-preview-button';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
@ -32,8 +35,11 @@ export default async function CompletedSigningPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
const document = await getDocumentAndSenderByToken({
|
const document = await getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
|
requireAccessAuth: false,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
if (!document || !document.documentData) {
|
if (!document || !document.documentData) {
|
||||||
@ -53,6 +59,17 @@ export default async function CompletedSigningPage({
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDocumentAccessValid) {
|
||||||
|
return <SigningAuthPageView email={recipient.email} />;
|
||||||
|
}
|
||||||
|
|
||||||
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
|
const signatures = await getRecipientSignatures({ recipientId: recipient.id });
|
||||||
|
|
||||||
const recipientName =
|
const recipientName =
|
||||||
|
|||||||
@ -11,6 +11,9 @@ 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 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';
|
||||||
@ -38,12 +41,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();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
@ -53,16 +56,23 @@ export const DateField = ({
|
|||||||
|
|
||||||
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
|
||||||
|
|
||||||
const onSign = async () => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -0,0 +1,172 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuth2FAProps = {
|
||||||
|
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||||
|
actionVerb?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Z2FAAuthFormSchema = z.object({
|
||||||
|
token: z
|
||||||
|
.string()
|
||||||
|
.min(4, { message: 'Token must at least 4 characters long' })
|
||||||
|
.max(10, { message: 'Token must be at most 10 characters long' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentActionAuth2FA = ({
|
||||||
|
actionTarget = 'FIELD',
|
||||||
|
actionVerb = 'sign',
|
||||||
|
onReauthFormSubmit,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DocumentActionAuth2FAProps) => {
|
||||||
|
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||||
|
useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const form = useForm<T2FAAuthFormSchema>({
|
||||||
|
resolver: zodResolver(Z2FAAuthFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
|
||||||
|
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
||||||
|
try {
|
||||||
|
setIsCurrentlyAuthenticating(true);
|
||||||
|
|
||||||
|
await onReauthFormSubmit({
|
||||||
|
type: DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
setFormErrorCode(error.code);
|
||||||
|
|
||||||
|
// Todo: Alert.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
token: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIs2FASetupSuccessful(false);
|
||||||
|
setFormErrorCode(null);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
<p>
|
||||||
|
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
|
||||||
|
? 'You need to setup 2FA to mark this document as viewed.'
|
||||||
|
: `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>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<EnableAuthenticatorAppDialog onSuccess={() => setIs2FASetupSuccessful(true)} />
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>2FA token</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Token" />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formErrorCode && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Unauthorized</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
We were unable to verify your details. Please try again or contact support
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuthAccountProps = {
|
||||||
|
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||||
|
actionVerb?: string;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentActionAuthAccount = ({
|
||||||
|
actionTarget = 'FIELD',
|
||||||
|
actionVerb = 'sign',
|
||||||
|
onOpenChange,
|
||||||
|
}: DocumentActionAuthAccountProps) => {
|
||||||
|
const { recipient } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
|
||||||
|
|
||||||
|
const handleChangeAccount = async (email: string) => {
|
||||||
|
try {
|
||||||
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
const encryptedEmail = await encryptSecondaryData({
|
||||||
|
data: email,
|
||||||
|
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await signOut({
|
||||||
|
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setIsSigningOut(false);
|
||||||
|
|
||||||
|
// Todo: Alert.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset disabled={isSigningOut} className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||||
|
<span>
|
||||||
|
To mark this document as viewed, you need to be logged in as{' '}
|
||||||
|
<strong>{recipient.email}</strong>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
||||||
|
in as <strong>{recipient.email}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button onClick={async () => handleChangeAccount(recipient.email)} loading={isSigningOut}>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DocumentAuth,
|
||||||
|
type TRecipientActionAuth,
|
||||||
|
type TRecipientActionAuthTypes,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import type { FieldType } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { DocumentActionAuth2FA } from './document-action-auth-2fa';
|
||||||
|
import { DocumentActionAuthAccount } from './document-action-auth-account';
|
||||||
|
import { DocumentActionAuthPasskey } from './document-action-auth-passkey';
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuthDialogProps = {
|
||||||
|
title?: string;
|
||||||
|
documentAuthType: TRecipientActionAuthTypes;
|
||||||
|
description?: string;
|
||||||
|
actionTarget: FieldType | 'DOCUMENT';
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The callback to run when the reauth form is filled out.
|
||||||
|
*/
|
||||||
|
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentActionAuthDialog = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
documentAuthType,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onReauthFormSubmit,
|
||||||
|
}: DocumentActionAuthDialogProps) => {
|
||||||
|
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const handleOnOpenChange = (value: boolean) => {
|
||||||
|
if (isCurrentlyAuthenticating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpenChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOnOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title || 'Sign field'}</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
{description || 'Reauthentication is required to sign this field'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{match({ documentAuthType, user })
|
||||||
|
.with(
|
||||||
|
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||||
|
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
||||||
|
() => <DocumentActionAuthAccount onOpenChange={onOpenChange} />,
|
||||||
|
)
|
||||||
|
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||||
|
<DocumentActionAuthPasskey
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
||||||
|
<DocumentActionAuth2FA
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
|
import { CreatePasskeyDialog } from '~/app/(dashboard)/settings/security/passkeys/create-passkey-dialog';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuthPasskeyProps = {
|
||||||
|
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||||
|
actionVerb?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZPasskeyAuthFormSchema = z.object({
|
||||||
|
passkeyId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TPasskeyAuthFormSchema = z.infer<typeof ZPasskeyAuthFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentActionAuthPasskey = ({
|
||||||
|
actionTarget = 'FIELD',
|
||||||
|
actionVerb = 'sign',
|
||||||
|
onReauthFormSubmit,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DocumentActionAuthPasskeyProps) => {
|
||||||
|
const {
|
||||||
|
recipient,
|
||||||
|
passkeyData,
|
||||||
|
preferredPasskeyId,
|
||||||
|
setPreferredPasskeyId,
|
||||||
|
isCurrentlyAuthenticating,
|
||||||
|
setIsCurrentlyAuthenticating,
|
||||||
|
refetchPasskeys,
|
||||||
|
} = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const form = useForm<TPasskeyAuthFormSchema>({
|
||||||
|
resolver: zodResolver(ZPasskeyAuthFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
passkeyId: preferredPasskeyId || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeyAuthenticationOptions } =
|
||||||
|
trpc.auth.createPasskeyAuthenticationOptions.useMutation();
|
||||||
|
|
||||||
|
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ passkeyId }: TPasskeyAuthFormSchema) => {
|
||||||
|
try {
|
||||||
|
setPreferredPasskeyId(passkeyId);
|
||||||
|
setIsCurrentlyAuthenticating(true);
|
||||||
|
|
||||||
|
const { options, tokenReference } = await createPasskeyAuthenticationOptions({
|
||||||
|
preferredPasskeyId: passkeyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticationResponse = await startAuthentication(options);
|
||||||
|
|
||||||
|
await onReauthFormSubmit({
|
||||||
|
type: DocumentAuth.PASSKEY,
|
||||||
|
authenticationResponse,
|
||||||
|
tokenReference,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
setFormErrorCode(error.code);
|
||||||
|
|
||||||
|
// Todo: Alert.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
passkeyId: preferredPasskeyId || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormErrorCode(null);
|
||||||
|
}, [open, form, preferredPasskeyId]);
|
||||||
|
|
||||||
|
if (!browserSupportsWebAuthn()) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
Your browser does not support passkeys, which is required to {actionVerb.toLowerCase()}{' '}
|
||||||
|
this {actionTarget.toLowerCase()}.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passkeyData.isInitialLoading || (passkeyData.isError && passkeyData.passkeys.length === 0)) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-28 items-center justify-center">
|
||||||
|
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passkeyData.isError) {
|
||||||
|
return (
|
||||||
|
<div className="h-28 space-y-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>Something went wrong while loading your passkeys.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="button" onClick={() => void refetchPasskeys()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passkeyData.passkeys.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
{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 ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<CreatePasskeyDialog
|
||||||
|
onSuccess={async () => refetchPasskeys()}
|
||||||
|
trigger={<Button>Setup</Button>}
|
||||||
|
/>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="passkeyId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>Passkey</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background text-muted-foreground">
|
||||||
|
<SelectValue
|
||||||
|
data-testid="documentAccessSelectValue"
|
||||||
|
placeholder="Select passkey"
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent position="popper">
|
||||||
|
{passkeyData.passkeys.map((passkey) => (
|
||||||
|
<SelectItem key={passkey.id} value={passkey.id}>
|
||||||
|
{passkey.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formErrorCode && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Unauthorized</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
We were unable to verify your details. Please try again or contact support
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||||
|
import type {
|
||||||
|
TDocumentAuthOptions,
|
||||||
|
TRecipientAccessAuthTypes,
|
||||||
|
TRecipientActionAuthTypes,
|
||||||
|
TRecipientAuthOptions,
|
||||||
|
} from '@documenso/lib/types/document-auth';
|
||||||
|
import { DocumentAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import {
|
||||||
|
type Document,
|
||||||
|
FieldType,
|
||||||
|
type Passkey,
|
||||||
|
type Recipient,
|
||||||
|
type User,
|
||||||
|
} from '@documenso/prisma/client';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
|
||||||
|
import type { DocumentActionAuthDialogProps } from './document-action-auth-dialog';
|
||||||
|
import { DocumentActionAuthDialog } from './document-action-auth-dialog';
|
||||||
|
|
||||||
|
type PasskeyData = {
|
||||||
|
passkeys: Omit<Passkey, 'credentialId' | 'credentialPublicKey'>[];
|
||||||
|
isInitialLoading: boolean;
|
||||||
|
isRefetching: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentAuthContextValue = {
|
||||||
|
executeActionAuthProcedure: (_value: ExecuteActionAuthProcedureOptions) => Promise<void>;
|
||||||
|
document: Document;
|
||||||
|
documentAuthOption: TDocumentAuthOptions;
|
||||||
|
setDocument: (_value: Document) => void;
|
||||||
|
recipient: Recipient;
|
||||||
|
recipientAuthOption: TRecipientAuthOptions;
|
||||||
|
setRecipient: (_value: Recipient) => void;
|
||||||
|
derivedRecipientAccessAuth: TRecipientAccessAuthTypes | null;
|
||||||
|
derivedRecipientActionAuth: TRecipientActionAuthTypes | null;
|
||||||
|
isAuthRedirectRequired: boolean;
|
||||||
|
isCurrentlyAuthenticating: boolean;
|
||||||
|
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||||
|
passkeyData: PasskeyData;
|
||||||
|
preferredPasskeyId: string | null;
|
||||||
|
setPreferredPasskeyId: (_value: string | null) => void;
|
||||||
|
user?: User | null;
|
||||||
|
refetchPasskeys: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocumentAuthContext = createContext<DocumentAuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useDocumentAuthContext = () => {
|
||||||
|
return useContext(DocumentAuthContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRequiredDocumentAuthContext = () => {
|
||||||
|
const context = useDocumentAuthContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Document auth context is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DocumentAuthProviderProps {
|
||||||
|
document: Document;
|
||||||
|
recipient: Recipient;
|
||||||
|
user?: User | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentAuthProvider = ({
|
||||||
|
document: initialDocument,
|
||||||
|
recipient: initialRecipient,
|
||||||
|
user,
|
||||||
|
children,
|
||||||
|
}: DocumentAuthProviderProps) => {
|
||||||
|
const [document, setDocument] = useState(initialDocument);
|
||||||
|
const [recipient, setRecipient] = useState(initialRecipient);
|
||||||
|
|
||||||
|
const [isCurrentlyAuthenticating, setIsCurrentlyAuthenticating] = useState(false);
|
||||||
|
const [preferredPasskeyId, setPreferredPasskeyId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
documentAuthOption,
|
||||||
|
recipientAuthOption,
|
||||||
|
derivedRecipientAccessAuth,
|
||||||
|
derivedRecipientActionAuth,
|
||||||
|
} = useMemo(
|
||||||
|
() =>
|
||||||
|
extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
}),
|
||||||
|
[document, recipient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const passkeyQuery = trpc.auth.findPasskeys.useQuery(
|
||||||
|
{
|
||||||
|
perPage: MAXIMUM_PASSKEYS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
enabled: derivedRecipientActionAuth === DocumentAuth.PASSKEY,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const passkeyData: PasskeyData = {
|
||||||
|
passkeys: passkeyQuery.data?.data || [],
|
||||||
|
isInitialLoading: passkeyQuery.isInitialLoading,
|
||||||
|
isRefetching: passkeyQuery.isRefetching,
|
||||||
|
isError: passkeyQuery.isError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [documentAuthDialogPayload, setDocumentAuthDialogPayload] =
|
||||||
|
useState<ExecuteActionAuthProcedureOptions | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pre calculated auth payload if the current user is authenticated correctly
|
||||||
|
* for the `derivedRecipientActionAuth`.
|
||||||
|
*
|
||||||
|
* Will be `null` if the user still requires authentication, or if they don't need
|
||||||
|
* authentication.
|
||||||
|
*/
|
||||||
|
const preCalculatedActionAuthOptions = match(derivedRecipientActionAuth)
|
||||||
|
.with(DocumentAuth.ACCOUNT, () => {
|
||||||
|
if (recipient.email !== user?.email) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: DocumentAuth.ACCOUNT,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||||
|
type: DocumentAuth.EXPLICIT_NONE,
|
||||||
|
}))
|
||||||
|
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
|
||||||
|
.exhaustive();
|
||||||
|
|
||||||
|
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||||
|
// Directly run callback if no auth required.
|
||||||
|
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
|
||||||
|
await options.onReauthFormSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run callback with precalculated auth options if available.
|
||||||
|
if (preCalculatedActionAuthOptions) {
|
||||||
|
setDocumentAuthDialogPayload(null);
|
||||||
|
await options.onReauthFormSubmit(preCalculatedActionAuthOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request the required auth from the user.
|
||||||
|
setDocumentAuthDialogPayload({
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { passkeys } = passkeyData;
|
||||||
|
|
||||||
|
if (!preferredPasskeyId && passkeys.length > 0) {
|
||||||
|
setPreferredPasskeyId(passkeys[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [passkeyData.passkeys]);
|
||||||
|
|
||||||
|
// Assume that a user must be logged in for any auth requirements.
|
||||||
|
const isAuthRedirectRequired = Boolean(
|
||||||
|
derivedRecipientActionAuth &&
|
||||||
|
derivedRecipientActionAuth !== DocumentAuth.EXPLICIT_NONE &&
|
||||||
|
user?.email !== recipient.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchPasskeys = async () => {
|
||||||
|
await passkeyQuery.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentAuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
setDocument,
|
||||||
|
executeActionAuthProcedure,
|
||||||
|
recipient,
|
||||||
|
setRecipient,
|
||||||
|
documentAuthOption,
|
||||||
|
recipientAuthOption,
|
||||||
|
derivedRecipientAccessAuth,
|
||||||
|
derivedRecipientActionAuth,
|
||||||
|
isAuthRedirectRequired,
|
||||||
|
isCurrentlyAuthenticating,
|
||||||
|
setIsCurrentlyAuthenticating,
|
||||||
|
passkeyData,
|
||||||
|
preferredPasskeyId,
|
||||||
|
setPreferredPasskeyId,
|
||||||
|
refetchPasskeys,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{documentAuthDialogPayload && derivedRecipientActionAuth && (
|
||||||
|
<DocumentActionAuthDialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={() => setDocumentAuthDialogPayload(null)}
|
||||||
|
onReauthFormSubmit={documentAuthDialogPayload.onReauthFormSubmit}
|
||||||
|
actionTarget={documentAuthDialogPayload.actionTarget}
|
||||||
|
documentAuthType={derivedRecipientActionAuth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DocumentAuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecuteActionAuthProcedureOptions = Omit<
|
||||||
|
DocumentActionAuthDialogProps,
|
||||||
|
'open' | 'onOpenChange' | 'documentAuthType' | 'recipientRole'
|
||||||
|
>;
|
||||||
|
|
||||||
|
DocumentAuthProvider.displayName = 'DocumentAuthProvider';
|
||||||
@ -6,6 +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 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';
|
||||||
@ -29,26 +32,33 @@ 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();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
const onSign = async () => {
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
try {
|
try {
|
||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: providedEmail ?? '',
|
value: providedEmail ?? '',
|
||||||
isBase64: false,
|
isBase64: false,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -41,10 +42,10 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
|||||||
const { mutateAsync: completeDocumentWithToken } =
|
const { mutateAsync: completeDocumentWithToken } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
const {
|
const { handleSubmit, formState } = useForm();
|
||||||
handleSubmit,
|
|
||||||
formState: { isSubmitting },
|
// Keep the loading state going if successful since the redirect may take some time.
|
||||||
} = useForm();
|
const isSubmitting = formState.isSubmitting || formState.isSubmitSuccessful;
|
||||||
|
|
||||||
const uninsertedFields = useMemo(() => {
|
const uninsertedFields = useMemo(() => {
|
||||||
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
return sortFieldsByPosition(fields.filter((field) => !field.inserted));
|
||||||
@ -64,9 +65,20 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await completeDocument();
|
||||||
|
|
||||||
|
// Reauth is currently not required for completing the document.
|
||||||
|
// await executeActionAuthProcedure({
|
||||||
|
// onReauthFormSubmit: completeDocument,
|
||||||
|
// actionTarget: 'DOCUMENT',
|
||||||
|
// });
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
||||||
await completeDocumentWithToken({
|
await completeDocumentWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
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';
|
||||||
@ -15,6 +18,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
@ -31,24 +35,50 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
const { fullName: providedFullName, setFullName: setProvidedFullName } =
|
||||||
useRequiredSigningContext();
|
useRequiredSigningContext();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
const [showFullNameModal, setShowFullNameModal] = useState(false);
|
const [showFullNameModal, setShowFullNameModal] = useState(false);
|
||||||
const [localFullName, setLocalFullName] = useState('');
|
const [localFullName, setLocalFullName] = useState('');
|
||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
const onPreSign = () => {
|
||||||
|
if (!providedFullName) {
|
||||||
|
setShowFullNameModal(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user clicks the sign button in the dialog where they enter their full name.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowFullNameModal(false);
|
||||||
|
setProvidedFullName(localFullName);
|
||||||
|
|
||||||
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName),
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth, name?: string) => {
|
||||||
try {
|
try {
|
||||||
if (!providedFullName && !localFullName) {
|
const value = name || providedFullName;
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
setShowFullNameModal(true);
|
setShowFullNameModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -56,18 +86,19 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
await signFieldWithToken({
|
await signFieldWithToken({
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: source === 'local' && localFullName ? localFullName : providedFullName ?? '',
|
value,
|
||||||
isBase64: false,
|
isBase64: false,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source === 'local' && !providedFullName) {
|
|
||||||
setProvidedFullName(localFullName);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalFullName('');
|
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -98,7 +129,13 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Name"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -147,10 +184,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localFullName}
|
disabled={!localFullName}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowFullNameModal(false);
|
|
||||||
void onSign('local');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,35 +1,24 @@
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
||||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
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 { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { DocumentAuthProvider } from './document-auth-provider';
|
||||||
|
|
||||||
import { DateField } from './date-field';
|
|
||||||
import { EmailField } from './email-field';
|
|
||||||
import { SigningForm } from './form';
|
|
||||||
import { NameField } from './name-field';
|
|
||||||
import { NoLongerAvailable } from './no-longer-available';
|
import { NoLongerAvailable } from './no-longer-available';
|
||||||
import { SigningProvider } from './provider';
|
import { SigningProvider } from './provider';
|
||||||
import { SignatureField } from './signature-field';
|
import { SigningAuthPageView } from './signing-auth-page';
|
||||||
import { TextField } from './text-field';
|
import { SigningPageView } from './signing-page-view';
|
||||||
|
|
||||||
export type SigningPageProps = {
|
export type SigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
@ -42,6 +31,8 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { user } = await getServerComponentSession();
|
||||||
|
|
||||||
const requestHeaders = Object.fromEntries(headers().entries());
|
const requestHeaders = Object.fromEntries(headers().entries());
|
||||||
|
|
||||||
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
|
||||||
@ -49,21 +40,40 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
const [document, fields, recipient] = await Promise.all([
|
const [document, fields, recipient] = await Promise.all([
|
||||||
getDocumentAndSenderByToken({
|
getDocumentAndSenderByToken({
|
||||||
token,
|
token,
|
||||||
|
userId: user?.id,
|
||||||
|
requireAccessAuth: false,
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
getFieldsForToken({ token }),
|
getFieldsForToken({ token }),
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
viewedDocument({ token, requestMetadata }).catch(() => null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!document || !document.documentData || !recipient) {
|
if (!document || !document.documentData || !recipient) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
const { documentData, documentMeta } = document;
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
document,
|
||||||
|
recipient,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
const { user } = await getServerComponentSession();
|
if (!isDocumentAccessValid) {
|
||||||
|
return <SigningAuthPageView email={recipient.email} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewedDocument({
|
||||||
|
token,
|
||||||
|
requestMetadata,
|
||||||
|
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
const { documentMeta } = document;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
document.status === DocumentStatus.COMPLETED ||
|
document.status === DocumentStatus.COMPLETED ||
|
||||||
@ -109,73 +119,9 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
fullName={user?.email === recipient.email ? user.name : recipient.name}
|
||||||
signature={user?.email === recipient.email ? user.signature : undefined}
|
signature={user?.email === recipient.email ? user.signature : undefined}
|
||||||
>
|
>
|
||||||
<div className="mx-auto w-full max-w-screen-xl">
|
<DocumentAuthProvider document={document} recipient={recipient} user={user}>
|
||||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
<SigningPageView recipient={recipient} document={document} fields={fields} />
|
||||||
{truncatedTitle}
|
</DocumentAuthProvider>
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
{document.User.name} ({document.User.email}) has invited you to{' '}
|
|
||||||
{recipient.role === RecipientRole.VIEWER && 'view'}
|
|
||||||
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
|
||||||
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
|
||||||
<Card
|
|
||||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
|
||||||
gradient
|
|
||||||
>
|
|
||||||
<CardContent className="p-2">
|
|
||||||
<LazyPDFViewer
|
|
||||||
key={documentData.id}
|
|
||||||
documentData={documentData}
|
|
||||||
document={document}
|
|
||||||
password={documentMeta?.password}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
|
||||||
<SigningForm
|
|
||||||
document={document}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
redirectUrl={documentMeta?.redirectUrl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
|
||||||
{fields.map((field) =>
|
|
||||||
match(field.type)
|
|
||||||
.with(FieldType.SIGNATURE, () => (
|
|
||||||
<SignatureField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.NAME, () => (
|
|
||||||
<NameField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.DATE, () => (
|
|
||||||
<DateField
|
|
||||||
key={field.id}
|
|
||||||
field={field}
|
|
||||||
recipient={recipient}
|
|
||||||
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
|
||||||
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
.with(FieldType.EMAIL, () => (
|
|
||||||
<EmailField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.with(FieldType.TEXT, () => (
|
|
||||||
<TextField key={field.id} field={field} recipient={recipient} />
|
|
||||||
))
|
|
||||||
.otherwise(() => null),
|
|
||||||
)}
|
|
||||||
</ElementVisible>
|
|
||||||
</div>
|
|
||||||
</SigningProvider>
|
</SigningProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,8 +33,28 @@ export const SignDialog = ({
|
|||||||
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) => {
|
||||||
|
if (isSubmitting || !isComplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reauth is currently not required for signing the document.
|
||||||
|
// if (isAuthRedirectRequired) {
|
||||||
|
// await executeActionAuthProcedure({
|
||||||
|
// actionTarget: 'DOCUMENT',
|
||||||
|
// onReauthFormSubmit: () => {
|
||||||
|
// // Do nothing since the user should be redirected.
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
setShowDialog(open);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
|
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useTransition } from 'react';
|
import { useMemo, useState, useTransition } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
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';
|
||||||
@ -15,6 +18,7 @@ import { Label } from '@documenso/ui/primitives/label';
|
|||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
@ -29,18 +33,21 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||||
useRequiredSigningContext();
|
useRequiredSigningContext();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const { Signature: signature } = field;
|
const { Signature: signature } = field;
|
||||||
|
|
||||||
@ -48,7 +55,6 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
|
|
||||||
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
||||||
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
||||||
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
|
||||||
|
|
||||||
const state = useMemo<SignatureFieldState>(() => {
|
const state = useMemo<SignatureFieldState>(() => {
|
||||||
if (!field.inserted) {
|
if (!field.inserted) {
|
||||||
@ -62,23 +68,38 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
return 'signed-text';
|
return 'signed-text';
|
||||||
}, [field.inserted, signature?.signatureImageAsBase64]);
|
}, [field.inserted, signature?.signatureImageAsBase64]);
|
||||||
|
|
||||||
useEffect(() => {
|
const onPreSign = () => {
|
||||||
if (!showSignatureModal && !isLocalSignatureSet) {
|
if (!providedSignature) {
|
||||||
setLocalSignature(null);
|
|
||||||
}
|
|
||||||
}, [showSignatureModal, isLocalSignatureSet]);
|
|
||||||
|
|
||||||
const onSign = async (source: 'local' | 'provider' = 'provider') => {
|
|
||||||
try {
|
|
||||||
if (!providedSignature && !localSignature) {
|
|
||||||
setIsLocalSignatureSet(false);
|
|
||||||
setShowSignatureModal(true);
|
setShowSignatureModal(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the user clicks the sign button in the dialog where they enter their signature.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowSignatureModal(false);
|
||||||
|
setProvidedSignature(localSignature);
|
||||||
|
|
||||||
|
if (!localSignature) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = source === 'local' && localSignature ? localSignature : providedSignature ?? '';
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
|
||||||
|
try {
|
||||||
|
const value = signature || providedSignature;
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
setShowSignatureModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,16 +108,17 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value,
|
value,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (source === 'local' && !providedSignature) {
|
|
||||||
setProvidedSignature(localSignature);
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocalSignature(null);
|
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -127,7 +149,13 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Signature"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -190,11 +218,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localSignature}
|
disabled={!localSignature}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowSignatureModal(false);
|
|
||||||
setIsLocalSignatureSet(true);
|
|
||||||
void onSign('local');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Sign
|
Sign
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type SigningAuthPageViewProps = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SigningAuthPageView = ({ email }: SigningAuthPageViewProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
|
||||||
|
|
||||||
|
const handleChangeAccount = async (email: string) => {
|
||||||
|
try {
|
||||||
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
const encryptedEmail = await encryptSecondaryData({
|
||||||
|
data: email,
|
||||||
|
expiresAt: DateTime.now().plus({ days: 1 }).toMillis(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await signOut({
|
||||||
|
callbackUrl: `/signin?email=${encodeURIComponent(encryptedEmail)}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'We were unable to log you out at this time.',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSigningOut(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold">Authentication required</h1>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
You need to be logged in as <strong>{email}</strong> to view this page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="mt-4 w-full"
|
||||||
|
type="submit"
|
||||||
|
onClick={async () => handleChangeAccount(email)}
|
||||||
|
loading={isSigningOut}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,15 +2,38 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
export type SignatureFieldProps = {
|
export type SignatureFieldProps = {
|
||||||
field: FieldWithSignature;
|
field: FieldWithSignature;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onSign?: () => Promise<void> | void;
|
|
||||||
|
/**
|
||||||
|
* A function that is called before the field requires to be signed, or reauthed.
|
||||||
|
*
|
||||||
|
* Example, you may want to show a dialog prior to signing where they can enter a value.
|
||||||
|
*
|
||||||
|
* Once that action is complete, you will need to call `executeActionAuthProcedure` to proceed
|
||||||
|
* regardless if it requires reauth or not.
|
||||||
|
*
|
||||||
|
* If the function returns true, we will proceed with the signing process. Otherwise if
|
||||||
|
* false is returned we will not proceed.
|
||||||
|
*/
|
||||||
|
onPreSign?: () => Promise<boolean> | boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The function required to be executed to insert the field.
|
||||||
|
*
|
||||||
|
* The auth values will be passed in if available.
|
||||||
|
*/
|
||||||
|
onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
onRemove?: () => Promise<void> | void;
|
onRemove?: () => Promise<void> | void;
|
||||||
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
type?: 'Date' | 'Email' | 'Name' | 'Signature';
|
||||||
tooltipText?: string | null;
|
tooltipText?: string | null;
|
||||||
@ -19,18 +42,56 @@ export type SignatureFieldProps = {
|
|||||||
export const SigningFieldContainer = ({
|
export const SigningFieldContainer = ({
|
||||||
field,
|
field,
|
||||||
loading,
|
loading,
|
||||||
|
onPreSign,
|
||||||
onSign,
|
onSign,
|
||||||
onRemove,
|
onRemove,
|
||||||
children,
|
children,
|
||||||
type,
|
type,
|
||||||
tooltipText,
|
tooltipText,
|
||||||
}: SignatureFieldProps) => {
|
}: SignatureFieldProps) => {
|
||||||
const onSignFieldClick = async () => {
|
const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext();
|
||||||
if (field.inserted) {
|
|
||||||
|
const handleInsertField = async () => {
|
||||||
|
if (field.inserted || !onSign) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSign?.();
|
// Bypass reauth for non signature fields.
|
||||||
|
if (field.type !== FieldType.SIGNATURE) {
|
||||||
|
const presignResult = await onPreSign?.();
|
||||||
|
|
||||||
|
if (presignResult === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSign();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthRedirectRequired) {
|
||||||
|
await executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: () => {
|
||||||
|
// Do nothing since the user should be redirected.
|
||||||
|
},
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any presign requirements, and halt if required.
|
||||||
|
if (onPreSign) {
|
||||||
|
const preSignResult = await onPreSign();
|
||||||
|
|
||||||
|
if (preSignResult === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: onSign,
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoveSignedFieldClick = async () => {
|
const onRemoveSignedFieldClick = async () => {
|
||||||
@ -47,7 +108,7 @@ export const SigningFieldContainer = ({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="absolute inset-0 z-10 h-full w-full"
|
className="absolute inset-0 z-10 h-full w-full"
|
||||||
onClick={onSignFieldClick}
|
onClick={async () => handleInsertField()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
102
apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx
Normal file
102
apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||||
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
|
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
|
||||||
|
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
|
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||||
|
import { FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
|
||||||
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { DateField } from './date-field';
|
||||||
|
import { EmailField } from './email-field';
|
||||||
|
import { SigningForm } from './form';
|
||||||
|
import { NameField } from './name-field';
|
||||||
|
import { SignatureField } from './signature-field';
|
||||||
|
import { TextField } from './text-field';
|
||||||
|
|
||||||
|
export type SigningPageViewProps = {
|
||||||
|
document: DocumentAndSender;
|
||||||
|
recipient: Recipient;
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SigningPageView = ({ document, recipient, fields }: SigningPageViewProps) => {
|
||||||
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
|
const { documentData, documentMeta } = document;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-screen-xl">
|
||||||
|
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
|
||||||
|
{truncatedTitle}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{document.User.name} ({document.User.email}) has invited you to{' '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'view'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||||
|
<Card
|
||||||
|
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||||
|
gradient
|
||||||
|
>
|
||||||
|
<CardContent className="p-2">
|
||||||
|
<LazyPDFViewer
|
||||||
|
key={documentData.id}
|
||||||
|
documentData={documentData}
|
||||||
|
document={document}
|
||||||
|
password={documentMeta?.password}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||||
|
<SigningForm
|
||||||
|
document={document}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
redirectUrl={documentMeta?.redirectUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||||
|
{fields.map((field) =>
|
||||||
|
match(field.type)
|
||||||
|
.with(FieldType.SIGNATURE, () => (
|
||||||
|
<SignatureField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.NAME, () => (
|
||||||
|
<NameField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.DATE, () => (
|
||||||
|
<DateField
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
recipient={recipient}
|
||||||
|
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||||
|
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.with(FieldType.EMAIL, () => (
|
||||||
|
<EmailField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.with(FieldType.TEXT, () => (
|
||||||
|
<TextField key={field.id} field={field} recipient={recipient} />
|
||||||
|
))
|
||||||
|
.otherwise(() => null),
|
||||||
|
)}
|
||||||
|
</ElementVisible>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,6 +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 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';
|
||||||
@ -15,6 +18,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
|||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
import { SigningFieldContainer } from './signing-field-container';
|
import { SigningFieldContainer } from './signing-field-container';
|
||||||
|
|
||||||
export type TextFieldProps = {
|
export type TextFieldProps = {
|
||||||
@ -27,36 +31,52 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||||
trpc.field.signFieldWithToken.useMutation();
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeSignedFieldWithToken,
|
mutateAsync: removeSignedFieldWithToken,
|
||||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||||
|
|
||||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||||
|
|
||||||
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
|
||||||
const [localText, setLocalCustomText] = useState('');
|
const [localText, setLocalCustomText] = useState('');
|
||||||
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showCustomTextModal && !isLocalSignatureSet) {
|
if (!showCustomTextModal) {
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
}
|
}
|
||||||
}, [showCustomTextModal, isLocalSignatureSet]);
|
}, [showCustomTextModal]);
|
||||||
|
|
||||||
const onSign = async () => {
|
/**
|
||||||
try {
|
* When the user clicks the sign button in the dialog where they enter the text field.
|
||||||
|
*/
|
||||||
|
const onDialogSignClick = () => {
|
||||||
|
setShowCustomTextModal(false);
|
||||||
|
|
||||||
|
void executeActionAuthProcedure({
|
||||||
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||||
|
actionTarget: field.type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPreSign = () => {
|
||||||
if (!localText) {
|
if (!localText) {
|
||||||
setIsLocalSignatureSet(false);
|
|
||||||
setShowCustomTextModal(true);
|
setShowCustomTextModal(true);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSign = async (authOptions?: TRecipientActionAuth) => {
|
||||||
|
try {
|
||||||
if (!localText) {
|
if (!localText) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -66,12 +86,19 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
fieldId: field.id,
|
fieldId: field.id,
|
||||||
value: localText,
|
value: localText,
|
||||||
isBase64: true,
|
isBase64: true,
|
||||||
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
setLocalCustomText('');
|
setLocalCustomText('');
|
||||||
|
|
||||||
startTransition(() => router.refresh());
|
startTransition(() => router.refresh());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -102,7 +129,13 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
|
<SigningFieldContainer
|
||||||
|
field={field}
|
||||||
|
onPreSign={onPreSign}
|
||||||
|
onSign={onSign}
|
||||||
|
onRemove={onRemove}
|
||||||
|
type="Signature"
|
||||||
|
>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||||
@ -149,11 +182,7 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
disabled={!localText}
|
disabled={!localText}
|
||||||
onClick={() => {
|
onClick={() => onDialogSignClick()}
|
||||||
setShowCustomTextModal(false);
|
|
||||||
setIsLocalSignatureSet(true);
|
|
||||||
void onSign();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Save Text
|
Save Text
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { StackAvatar } from './stack-avatar';
|
import { StackAvatar } from './stack-avatar';
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,10 @@ 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 {
|
||||||
@ -82,6 +86,10 @@ 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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -5,11 +5,18 @@ import { cn } from '@documenso/ui/lib/utils';
|
|||||||
export type SettingsHeaderProps = {
|
export type SettingsHeaderProps = {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
hideDivider?: boolean;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
|
export const SettingsHeader = ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
className,
|
||||||
|
hideDivider,
|
||||||
|
}: SettingsHeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn('flex flex-row items-center justify-between', className)}>
|
<div className={cn('flex flex-row items-center justify-between', className)}>
|
||||||
@ -22,7 +29,7 @@ export const SettingsHeader = ({ children, title, subtitle, className }: Setting
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="my-4" />
|
{!hideDivider && <hr className="my-4" />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { SVGAttributes } from 'react';
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
export type LogoProps = SVGAttributes<SVGSVGElement>;
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { UAParser } from 'ua-parser-js';
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_EMAIL_FORMAT } from '@documenso/lib/constants/document-audit-logs';
|
||||||
|
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||||
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 { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
import { formatDocumentAuditLogActionString } from '@documenso/lib/utils/document-audit-logs';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
@ -79,7 +80,11 @@ export const DocumentHistorySheet = ({
|
|||||||
* @param text The text to format
|
* @param text The text to format
|
||||||
* @returns The formatted text
|
* @returns The formatted text
|
||||||
*/
|
*/
|
||||||
const formatGenericText = (text: string) => {
|
const formatGenericText = (text?: string | null) => {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
return (text.charAt(0).toUpperCase() + text.slice(1).toLowerCase()).replaceAll('_', ' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -219,6 +224,24 @@ export const DocumentHistorySheet = ({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.with(
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED },
|
||||||
|
{ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED },
|
||||||
|
({ data }) => (
|
||||||
|
<DocumentHistorySheetChanges
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
key: 'Old',
|
||||||
|
value: DOCUMENT_AUTH_TYPES[data.from || '']?.value || 'None',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'New',
|
||||||
|
value: DOCUMENT_AUTH_TYPES[data.to || '']?.value || 'None',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, ({ data }) => {
|
||||||
if (data.changes.length === 0) {
|
if (data.changes.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@ -281,6 +304,7 @@ export const DocumentHistorySheet = ({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
|
|
||||||
{isUserDetailsVisible && (
|
{isUserDetailsVisible && (
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
|
||||||
|
|
||||||
export type FormErrorMessageProps = {
|
|
||||||
className?: string;
|
|
||||||
error: { message?: string } | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FormErrorMessage = ({ error, className }: FormErrorMessageProps) => {
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{error && (
|
|
||||||
<motion.p
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
y: -10,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
opacity: 0,
|
|
||||||
y: 10,
|
|
||||||
}}
|
|
||||||
className={cn('text-xs text-red-500', className)}
|
|
||||||
>
|
|
||||||
{error.message}
|
|
||||||
</motion.p>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
import { Globe, Lock } from 'lucide-react';
|
import { Globe, Lock } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||||
|
|
||||||
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
type TemplateTypeIcon = {
|
type TemplateTypeIcon = {
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog';
|
|
||||||
import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog';
|
|
||||||
|
|
||||||
type AuthenticatorAppProps = {
|
|
||||||
isTwoFactorEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => {
|
|
||||||
const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null);
|
|
||||||
|
|
||||||
const isEnableDialogOpen = modalState === 'enable';
|
|
||||||
const isDisableDialogOpen = modalState === 'disable';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
{isTwoFactorEnabled ? (
|
|
||||||
<Button variant="destructive" onClick={() => setModalState('disable')}>
|
|
||||||
Disable 2FA
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnableAuthenticatorAppDialog
|
|
||||||
open={isEnableDialogOpen}
|
|
||||||
onOpenChange={(open) => !open && setModalState(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DisableAuthenticatorAppDialog
|
|
||||||
open={isDisableDialogOpen}
|
|
||||||
onOpenChange={(open) => !open && setModalState(null)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,3 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@ -9,65 +13,51 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
export const ZDisable2FAForm = z.object({
|
||||||
password: z.string().min(6).max(72),
|
token: z.string(),
|
||||||
backupCode: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
|
||||||
typeof ZDisableTwoFactorAuthenticationForm
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type DisableAuthenticatorAppDialogProps = {
|
export const DisableAuthenticatorAppDialog = () => {
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DisableAuthenticatorAppDialog = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: DisableAuthenticatorAppDialogProps) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: disableTwoFactorAuthentication } =
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
trpc.twoFactorAuthentication.disable.useMutation();
|
|
||||||
|
|
||||||
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
|
||||||
|
|
||||||
|
const disable2FAForm = useForm<TDisable2FAForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
password: '',
|
token: '',
|
||||||
backupCode: '',
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
resolver: zodResolver(ZDisable2FAForm),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
|
||||||
disableTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => {
|
||||||
password,
|
|
||||||
backupCode,
|
|
||||||
}: TDisableTwoFactorAuthenticationForm) => {
|
|
||||||
try {
|
try {
|
||||||
await disableTwoFactorAuthentication({ password, backupCode });
|
await disable2FA({ token });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Two-factor authentication disabled',
|
title: 'Two-factor authentication disabled',
|
||||||
@ -76,7 +66,7 @@ export const DisableAuthenticatorAppDialog = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
onOpenChange(false);
|
setIsOpen(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@ -91,74 +81,51 @@ export const DisableAuthenticatorAppDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
<DialogTrigger asChild={true}>
|
||||||
|
<Button className="flex-shrink-0" variant="destructive">
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Disable Authenticator App</DialogTitle>
|
<DialogTitle>Disable 2FA</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
To disable the Authenticator App for your account, please enter your password and a
|
Please provide a token from the authenticator, or a backup code. If you do not have a
|
||||||
backup code. If you do not have a backup code available, please contact support.
|
backup code available, please contact support.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...disableTwoFactorAuthenticationForm}>
|
<Form {...disable2FAForm}>
|
||||||
<form
|
<form onSubmit={disable2FAForm.handleSubmit(onDisable2FAFormSubmit)}>
|
||||||
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
<fieldset className="flex flex-col gap-y-4" disabled={isDisable2FASubmitting}>
|
||||||
onDisableTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<fieldset
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
disabled={isDisableTwoFactorAuthenticationSubmitting}
|
|
||||||
>
|
|
||||||
<FormField
|
<FormField
|
||||||
name="password"
|
name="token"
|
||||||
control={disableTwoFactorAuthenticationForm.control}
|
control={disable2FAForm.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput
|
<Input {...field} placeholder="Token" />
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
name="backupCode"
|
|
||||||
control={disableTwoFactorAuthenticationForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input {...field} type="text" value={field.value ?? ''} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
<Button
|
<Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
|
||||||
type="submit"
|
|
||||||
variant="destructive"
|
|
||||||
loading={isDisableTwoFactorAuthenticationSubmitting}
|
|
||||||
>
|
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
import { renderSVG } from 'uqr';
|
import { renderSVG } from 'uqr';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@ -11,11 +14,13 @@ import { trpc } from '@documenso/trpc/react';
|
|||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -26,98 +31,79 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
export const ZSetupTwoFactorAuthenticationForm = z.object({
|
export const ZEnable2FAForm = z.object({
|
||||||
password: z.string().min(6).max(72),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
|
|
||||||
|
|
||||||
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
|
||||||
|
|
||||||
export type EnableAuthenticatorAppDialogProps = {
|
export type EnableAuthenticatorAppDialogProps = {
|
||||||
open: boolean;
|
onSuccess?: () => void;
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnableAuthenticatorAppDialog = ({
|
export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorAppDialogProps) => {
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: EnableAuthenticatorAppDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
const router = useRouter();
|
||||||
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: enableTwoFactorAuthentication,
|
mutateAsync: setup2FA,
|
||||||
data: enableTwoFactorAuthenticationData,
|
data: setup2FAData,
|
||||||
isLoading: isEnableTwoFactorAuthenticationDataLoading,
|
isLoading: isSettingUp2FA,
|
||||||
} = trpc.twoFactorAuthentication.enable.useMutation();
|
} = trpc.twoFactorAuthentication.setup.useMutation({
|
||||||
|
onError: () => {
|
||||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
toast({
|
||||||
defaultValues: {
|
title: 'Unable to setup two-factor authentication',
|
||||||
password: '',
|
description:
|
||||||
|
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
const enable2FAForm = useForm<TEnable2FAForm>({
|
||||||
setupTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
token: '',
|
token: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
resolver: zodResolver(ZEnable2FAForm),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
|
||||||
enableTwoFactorAuthenticationForm.formState;
|
|
||||||
|
|
||||||
const step = useMemo(() => {
|
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
||||||
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
|
|
||||||
return 'setup';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
|
|
||||||
return 'enable';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'view';
|
|
||||||
}, [
|
|
||||||
setupTwoFactorAuthenticationData,
|
|
||||||
isSetupTwoFactorAuthenticationSubmitting,
|
|
||||||
enableTwoFactorAuthenticationData,
|
|
||||||
isEnableTwoFactorAuthenticationSubmitting,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onSetupTwoFactorAuthenticationFormSubmit = async ({
|
|
||||||
password,
|
|
||||||
}: TSetupTwoFactorAuthenticationForm) => {
|
|
||||||
try {
|
try {
|
||||||
await setupTwoFactorAuthentication({ password });
|
const data = await enable2FA({ code: token });
|
||||||
|
|
||||||
|
setRecoveryCodes(data.recoveryCodes);
|
||||||
|
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 password correctly and try again.',
|
'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',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadRecoveryCodes = () => {
|
const downloadRecoveryCodes = () => {
|
||||||
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
|
if (recoveryCodes) {
|
||||||
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
|
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -128,111 +114,80 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
const handleEnable2FA = async () => {
|
||||||
token,
|
if (!setup2FAData) {
|
||||||
}: TEnableTwoFactorAuthenticationForm) => {
|
await setup2FA();
|
||||||
try {
|
|
||||||
await enableTwoFactorAuthentication({ code: token });
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: 'Two-factor authentication enabled',
|
|
||||||
description:
|
|
||||||
'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.',
|
|
||||||
});
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to setup two-factor authentication',
|
|
||||||
description:
|
|
||||||
'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset the form when the Dialog closes
|
enable2FAForm.reset();
|
||||||
if (!open) {
|
|
||||||
setupTwoFactorAuthenticationForm.reset();
|
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||||
|
setRecoveryCodes(null);
|
||||||
|
router.refresh();
|
||||||
}
|
}
|
||||||
}, [open, setupTwoFactorAuthenticationForm]);
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
<DialogTrigger asChild={true}>
|
||||||
|
<Button
|
||||||
|
className="flex-shrink-0"
|
||||||
|
loading={isSettingUp2FA}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleEnable2FA();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent position="center">
|
||||||
|
{setup2FAData && (
|
||||||
|
<>
|
||||||
|
{recoveryCodes ? (
|
||||||
|
<div>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
<DialogTitle>Backup codes</DialogTitle>
|
||||||
|
|
||||||
{step === 'setup' && (
|
|
||||||
<DialogDescription>
|
|
||||||
To enable two-factor authentication, please enter your password below.
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'view' && (
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Your recovery codes are listed below. Please store them in a safe place.
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
)}
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{match(step)
|
<div className="mt-4">
|
||||||
.with('setup', () => {
|
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||||
return (
|
</div>
|
||||||
<Form {...setupTwoFactorAuthenticationForm}>
|
|
||||||
<form
|
|
||||||
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
|
|
||||||
onSetupTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
name="password"
|
|
||||||
control={setupTwoFactorAuthenticationForm.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput
|
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="mt-4">
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
<DialogClose asChild>
|
||||||
Cancel
|
<Button variant="secondary">Close</Button>
|
||||||
</Button>
|
</DialogClose>
|
||||||
|
|
||||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
<Button onClick={downloadRecoveryCodes}>Download</Button>
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</div>
|
||||||
</Form>
|
) : (
|
||||||
);
|
<Form {...enable2FAForm}>
|
||||||
})
|
<form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
|
||||||
.with('enable', () => (
|
<DialogHeader>
|
||||||
<Form {...enableTwoFactorAuthenticationForm}>
|
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||||
<form
|
<DialogDescription>
|
||||||
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
|
||||||
onEnableTwoFactorAuthenticationFormSubmit,
|
|
||||||
)}
|
|
||||||
className="flex flex-col gap-y-4"
|
|
||||||
>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
To enable two-factor authentication, scan the following QR code using your
|
To enable two-factor authentication, scan the following QR code using your
|
||||||
authenticator app.
|
authenticator app.
|
||||||
</p>
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4">
|
||||||
<div
|
<div
|
||||||
className="flex h-36 justify-center"
|
className="flex h-36 justify-center"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
__html: renderSVG(setup2FAData?.uri ?? ''),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -242,7 +197,7 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||||
{setupTwoFactorAuthenticationData?.secret}
|
{setup2FAData?.secret}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
@ -252,7 +207,7 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
name="token"
|
name="token"
|
||||||
control={enableTwoFactorAuthenticationForm.control}
|
control={enable2FAForm.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||||
@ -265,38 +220,20 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
<DialogClose asChild>
|
||||||
Cancel
|
<Button variant="secondary">Cancel</Button>
|
||||||
</Button>
|
</DialogClose>
|
||||||
|
|
||||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
<Button type="submit" loading={isEnabling2FA}>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
))
|
|
||||||
.with('view', () => (
|
|
||||||
<div>
|
|
||||||
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
|
||||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
)}
|
||||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={downloadRecoveryCodes}
|
|
||||||
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
|
|
||||||
loading={isEnableTwoFactorAuthenticationDataLoading}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
.exhaustive()}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
|
|
||||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
|
||||||
|
|
||||||
type RecoveryCodesProps = {
|
|
||||||
isTwoFactorEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
className="flex-shrink-0"
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
disabled={!isTwoFactorEnabled}
|
|
||||||
>
|
|
||||||
View Codes
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<ViewRecoveryCodesDialog
|
|
||||||
key={isOpen ? 'open' : 'closed'}
|
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
@ -6,69 +8,58 @@ import { match } from 'ts-pattern';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
export const ZViewRecoveryCodesForm = z.object({
|
export const ZViewRecoveryCodesForm = z.object({
|
||||||
password: z.string().min(6).max(72),
|
token: z.string().min(1, { message: 'Token is required' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
export type TViewRecoveryCodesForm = z.infer<typeof ZViewRecoveryCodesForm>;
|
||||||
|
|
||||||
export type ViewRecoveryCodesDialogProps = {
|
export const ViewRecoveryCodesDialog = () => {
|
||||||
open: boolean;
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
onOpenChange: (_open: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: viewRecoveryCodes,
|
data: recoveryCodes,
|
||||||
data: viewRecoveryCodesData,
|
mutate,
|
||||||
isLoading: isViewRecoveryCodesDataLoading,
|
isLoading,
|
||||||
|
error,
|
||||||
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||||
|
|
||||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
password: '',
|
token: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
|
||||||
|
|
||||||
const step = useMemo(() => {
|
|
||||||
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
|
||||||
return 'authenticate';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'view';
|
|
||||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
|
||||||
|
|
||||||
const downloadRecoveryCodes = () => {
|
const downloadRecoveryCodes = () => {
|
||||||
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
|
if (recoveryCodes) {
|
||||||
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
|
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||||
type: 'text/plain',
|
type: 'text/plain',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -79,105 +70,88 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
|
||||||
try {
|
|
||||||
await viewRecoveryCodes({ password });
|
|
||||||
} catch (_err) {
|
|
||||||
toast({
|
|
||||||
title: 'Unable to view recovery codes',
|
|
||||||
description:
|
|
||||||
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Reset the form when the Dialog closes
|
|
||||||
if (!open) {
|
|
||||||
viewRecoveryCodesForm.reset();
|
|
||||||
}
|
|
||||||
}, [open, viewRecoveryCodesForm]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="flex-shrink-0">View Codes</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
<DialogHeader>
|
{recoveryCodes ? (
|
||||||
|
<div>
|
||||||
|
<DialogHeader className="mb-4">
|
||||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||||
|
|
||||||
{step === 'authenticate' && (
|
|
||||||
<DialogDescription>
|
|
||||||
To view your recovery codes, please enter your password below.
|
|
||||||
</DialogDescription>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'view' && (
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Your recovery codes are listed below. Please store them in a safe place.
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
)}
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{match(step)
|
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||||
.with('authenticate', () => {
|
|
||||||
return (
|
<DialogFooter className="mt-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="secondary">Close</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button onClick={downloadRecoveryCodes}>Download</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Form {...viewRecoveryCodesForm}>
|
<Form {...viewRecoveryCodesForm}>
|
||||||
<form
|
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
|
||||||
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
<DialogHeader className="mb-4">
|
||||||
className="flex flex-col gap-y-4"
|
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||||
>
|
|
||||||
|
<DialogDescription>
|
||||||
|
Please provide a token from your authenticator, or a backup code.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col space-y-4" disabled={isLoading}>
|
||||||
<FormField
|
<FormField
|
||||||
name="password"
|
name="token"
|
||||||
control={viewRecoveryCodesForm.control}
|
control={viewRecoveryCodesForm.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput
|
<Input {...field} placeholder="Token" />
|
||||||
{...field}
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={field.value ?? ''}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
{error && (
|
||||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
<Alert variant="destructive">
|
||||||
Cancel
|
<AlertDescription>
|
||||||
</Button>
|
{match(AppError.parseError(error).message)
|
||||||
|
.with(
|
||||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
ErrorCode.INCORRECT_TWO_FACTOR_CODE,
|
||||||
Continue
|
() => 'Invalid code. Please try again.',
|
||||||
</Button>
|
)
|
||||||
</DialogFooter>
|
.otherwise(
|
||||||
</form>
|
() => 'Something went wrong. Please try again or contact support.',
|
||||||
</Form>
|
)}
|
||||||
);
|
</AlertDescription>
|
||||||
})
|
</Alert>
|
||||||
.with('view', () => (
|
|
||||||
<div>
|
|
||||||
{viewRecoveryCodesData?.recoveryCodes && (
|
|
||||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
<DialogFooter>
|
||||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
<Button
|
Cancel
|
||||||
variant="secondary"
|
|
||||||
disabled={!viewRecoveryCodesData?.recoveryCodes}
|
|
||||||
loading={isViewRecoveryCodesDataLoading}
|
|
||||||
onClick={downloadRecoveryCodes}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogClose>
|
||||||
</div>
|
|
||||||
))
|
<Button type="submit" loading={isLoading}>
|
||||||
.exhaustive()}
|
View
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -55,11 +55,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitting = form.formState.isSubmitting;
|
const isSubmitting = form.formState.isSubmitting;
|
||||||
const hasTwoFactorAuthentication = user.twoFactorEnabled;
|
|
||||||
|
|
||||||
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
|
||||||
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
|
|
||||||
trpc.profile.deleteAccount.useMutation();
|
|
||||||
|
|
||||||
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -154,7 +154,7 @@ export const ClaimPublicProfileDialogForm = ({
|
|||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|
||||||
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block truncate rounded-md px-2 py-1 text-sm">
|
<div className="bg-muted/50 text-muted-foreground mt-2 inline-block max-w-[29rem] truncate rounded-md px-2 py-1 text-sm lowercase">
|
||||||
{baseUrl.host}/u/{field.value || '<username>'}
|
{baseUrl.host}/u/{field.value || '<username>'}
|
||||||
</div>
|
</div>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@ -6,12 +6,18 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
|
||||||
|
import { KeyRoundIcon } from 'lucide-react';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -66,14 +72,24 @@ export type SignInFormProps = {
|
|||||||
|
|
||||||
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { getFlag } = useFeatureFlags();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
>('totp');
|
>('totp');
|
||||||
|
|
||||||
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
|
const isPasskeyEnabled = getFlag('app_passkey');
|
||||||
|
|
||||||
|
const { mutateAsync: createPasskeySigninOptions } =
|
||||||
|
trpc.auth.createPasskeySigninOptions.useMutation();
|
||||||
|
|
||||||
const form = useForm<TSignInFormSchema>({
|
const form = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: initialEmail ?? '',
|
email: initialEmail ?? '',
|
||||||
@ -107,6 +123,63 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
setTwoFactorAuthenticationMethod(method);
|
setTwoFactorAuthenticationMethod(method);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignInWithPasskey = async () => {
|
||||||
|
if (!browserSupportsWebAuthn()) {
|
||||||
|
toast({
|
||||||
|
title: 'Not supported',
|
||||||
|
description: 'Passkeys are not supported on this browser',
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsPasskeyLoading(true);
|
||||||
|
|
||||||
|
const options = await createPasskeySigninOptions();
|
||||||
|
|
||||||
|
const credential = await startAuthentication(options);
|
||||||
|
|
||||||
|
const result = await signIn('webauthn', {
|
||||||
|
credential: JSON.stringify(credential),
|
||||||
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.url || result.error) {
|
||||||
|
throw new AppError(result?.error ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = result.url;
|
||||||
|
} catch (err) {
|
||||||
|
setIsPasskeyLoading(false);
|
||||||
|
|
||||||
|
if (err.name === 'NotAllowedError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
const errorMessage = match(error.code)
|
||||||
|
.with(
|
||||||
|
AppErrorCode.NOT_SETUP,
|
||||||
|
() =>
|
||||||
|
'This passkey is not configured for this application. Please login and add one in the user settings.',
|
||||||
|
)
|
||||||
|
.with(AppErrorCode.EXPIRED_CODE, () => 'This session has expired. Please try again.')
|
||||||
|
.otherwise(() => 'Please try again later or login using your normal details');
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: errorMessage,
|
||||||
|
duration: 10000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const credentials: Record<string, string> = {
|
const credentials: Record<string, string> = {
|
||||||
@ -189,7 +262,10 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
className={cn('flex w-full flex-col gap-y-4', className)}
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
||||||
onSubmit={form.handleSubmit(onFormSubmit)}
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
||||||
>
|
>
|
||||||
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
<fieldset
|
||||||
|
className="flex w-full flex-col gap-y-4"
|
||||||
|
disabled={isSubmitting || isPasskeyLoading}
|
||||||
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
@ -217,6 +293,8 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
<PasswordInput {...field} />
|
<PasswordInput {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
|
||||||
<p className="mt-2 text-right">
|
<p className="mt-2 text-right">
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
@ -225,11 +303,9 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
Forgot your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@ -240,14 +316,15 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
|
||||||
<>
|
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGoogleSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
@ -259,8 +336,23 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isPasskeyEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
loading={isPasskeyLoading}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
onClick={onSignInWithPasskey}
|
||||||
|
>
|
||||||
|
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
|
||||||
|
Passkey
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { SVGAttributes } from 'react';
|
import type { SVGAttributes } from 'react';
|
||||||
|
|
||||||
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
export type BackgroundProps = Omit<SVGAttributes<SVGElement>, 'viewBox'>;
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm">
|
<div className="border-border bg-background text-muted-foreground inline-block max-w-full truncate rounded-md border px-2.5 py-1.5 text-sm lowercase">
|
||||||
{baseUrl.host}/u/{user.url}
|
{baseUrl.host}/u/{user.url}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
import { ThemeProviderProps } from 'next-themes/dist/types';
|
import type { ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
|||||||
@ -115,6 +115,7 @@ 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,6 +34,7 @@ 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,7 +1,15 @@
|
|||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const eslint = (filenames) =>
|
||||||
|
`eslint --fix ${filenames.map((f) => `"${path.relative(process.cwd(), f)}"`).join(' ')}`;
|
||||||
|
|
||||||
|
const prettier = (filenames) =>
|
||||||
|
`prettier --write ${filenames.map((f) => `"${path.relative(process.cwd(), f)}"`).join(' ')}`;
|
||||||
|
|
||||||
/** @type {import('lint-staged').Config} */
|
/** @type {import('lint-staged').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
'**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`),
|
'**/*.{ts,tsx,cts,mts}': [eslint, prettier],
|
||||||
'**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`),
|
'**/*.{js,jsx,cjs,mjs}': [prettier],
|
||||||
'**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`),
|
'**/*.{yml,mdx}': [prettier],
|
||||||
'**/*/package.json': 'npm run precommit',
|
'**/*/package.json': 'npm run precommit',
|
||||||
};
|
};
|
||||||
|
|||||||
4262
package-lock.json
generated
4262
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": "^8.0.0",
|
"husky": "^9.0.11",
|
||||||
"lint-staged": "^14.0.0",
|
"lint-staged": "^15.2.2",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"turbo": "^1.9.3"
|
"turbo": "^1.9.3"
|
||||||
@ -48,6 +48,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@documenso/pdf-sign": "^0.1.0",
|
||||||
"next-runtime-env": "^3.2.0"
|
"next-runtime-env": "^3.2.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
97
packages/app-tests/e2e/document-auth/access-auth.spec.ts
Normal file
97
packages/app-tests/e2e/document-auth/access-auth.spec.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should grant access when not required', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(user, [
|
||||||
|
recipientWithAccount,
|
||||||
|
'recipientwithoutaccount@documenso.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = recipients.map((recipient) => recipient.token);
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow or deny access when required', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const document = await seedPendingDocument(
|
||||||
|
user,
|
||||||
|
[recipientWithAccount, 'recipientwithoutaccount@documenso.com'],
|
||||||
|
{
|
||||||
|
createDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: 'ACCOUNT',
|
||||||
|
globalActionAuth: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that both are denied access.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { email, token } = recipient;
|
||||||
|
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipientWithAccount.email,
|
||||||
|
redirectPath: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that the one logged in is granted access.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { email, token } = recipient;
|
||||||
|
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
|
||||||
|
// Recipient should be granted access.
|
||||||
|
if (recipient.email === recipientWithAccount.email) {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient should still be denied.
|
||||||
|
if (recipient.email !== recipientWithAccount.email) {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Authentication required' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
418
packages/app-tests/e2e/document-auth/action-auth.spec.ts
Normal file
418
packages/app-tests/e2e/document-auth/action-auth.spec.ts
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||||
|
import {
|
||||||
|
createDocumentAuthOptions,
|
||||||
|
createRecipientAuthOptions,
|
||||||
|
} from '@documenso/lib/utils/document-auth';
|
||||||
|
import { FieldType } from '@documenso/prisma/client';
|
||||||
|
import {
|
||||||
|
seedPendingDocumentNoFields,
|
||||||
|
seedPendingDocumentWithFullFields,
|
||||||
|
} from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedTestEmail, seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow signing when no auth setup', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [recipientWithAccount, seedTestEmail()],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that both are granted access.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.getByLabel('Custom Text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [recipientWithAccount],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipientWithAccount.email,
|
||||||
|
redirectPath: signUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
// Add signature.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.getByLabel('Custom Text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Currently document auth for signing/approving/viewing is not required.
|
||||||
|
test.skip('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentNoFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [recipientWithAccount],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
const { token } = recipient;
|
||||||
|
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
|
'Reauthentication is required to sign the document',
|
||||||
|
);
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithAccount = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [recipientWithAccount, seedTestEmail()],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that both are denied access.
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
|
||||||
|
await page.goto(`/sign/${token}`);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
if (field.type !== FieldType.SIGNATURE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
|
'Reauthentication is required to sign this field',
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
await unseedUser(recipientWithAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient auth', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithInheritAuth = await seedUser();
|
||||||
|
const recipientWithExplicitNoneAuth = await seedUser();
|
||||||
|
const recipientWithExplicitAccountAuth = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [
|
||||||
|
recipientWithInheritAuth,
|
||||||
|
recipientWithExplicitNoneAuth,
|
||||||
|
recipientWithExplicitAccountAuth,
|
||||||
|
],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: 'EXPLICIT_NONE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fields: [FieldType.DATE],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||||
|
|
||||||
|
// This document has no global action auth, so only account should require auth.
|
||||||
|
const isAuthRequired = actionAuth === 'ACCOUNT';
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
if (isAuthRequired) {
|
||||||
|
for (const field of Field) {
|
||||||
|
if (field.type !== FieldType.SIGNATURE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
|
'Reauthentication is required to sign this field',
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in and it should work.
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipient.email,
|
||||||
|
redirectPath: signUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add signature.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.getByLabel('Custom Text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
if (isAuthRequired) {
|
||||||
|
await apiSignout({ page });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[DOCUMENT_AUTH]: should allow field signing when required for recipient and global auth', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const recipientWithInheritAuth = await seedUser();
|
||||||
|
const recipientWithExplicitNoneAuth = await seedUser();
|
||||||
|
const recipientWithExplicitAccountAuth = await seedUser();
|
||||||
|
|
||||||
|
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||||
|
owner: user,
|
||||||
|
recipients: [
|
||||||
|
recipientWithInheritAuth,
|
||||||
|
recipientWithExplicitNoneAuth,
|
||||||
|
recipientWithExplicitAccountAuth,
|
||||||
|
],
|
||||||
|
recipientsCreateOptions: [
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: 'EXPLICIT_NONE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authOptions: createRecipientAuthOptions({
|
||||||
|
accessAuth: null,
|
||||||
|
actionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fields: [FieldType.DATE],
|
||||||
|
updateDocumentOptions: {
|
||||||
|
authOptions: createDocumentAuthOptions({
|
||||||
|
globalAccessAuth: null,
|
||||||
|
globalActionAuth: 'ACCOUNT',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const { token, Field } = recipient;
|
||||||
|
const { actionAuth } = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
|
||||||
|
|
||||||
|
// This document HAS global action auth, so account and inherit should require auth.
|
||||||
|
const isAuthRequired = actionAuth === 'ACCOUNT' || actionAuth === null;
|
||||||
|
|
||||||
|
const signUrl = `/sign/${token}`;
|
||||||
|
|
||||||
|
await page.goto(signUrl);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||||
|
|
||||||
|
if (isAuthRequired) {
|
||||||
|
for (const field of Field) {
|
||||||
|
if (field.type !== FieldType.SIGNATURE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
await expect(page.getByRole('paragraph')).toContainText(
|
||||||
|
'Reauthentication is required to sign this field',
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign in and it should work.
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: recipient.email,
|
||||||
|
redirectPath: signUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add signature.
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of Field) {
|
||||||
|
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||||
|
|
||||||
|
if (field.type === FieldType.TEXT) {
|
||||||
|
await page.getByLabel('Custom Text').fill('TEXT');
|
||||||
|
await page.getByRole('button', { name: 'Save Text' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true', {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Complete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Sign' }).click();
|
||||||
|
await page.waitForURL(`${signUrl}/complete`);
|
||||||
|
|
||||||
|
if (isAuthRequired) {
|
||||||
|
await apiSignout({ page });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
200
packages/app-tests/e2e/document-flow/settings-step.spec.ts
Normal file
200
packages/app-tests/e2e/document-flow/settings-step.spec.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
seedBlankDocument,
|
||||||
|
seedDraftDocument,
|
||||||
|
seedPendingDocument,
|
||||||
|
} 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 { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
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 }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
const document = await seedBlankDocument(user);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${document.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set title.
|
||||||
|
await page.getByLabel('Title').fill('New Title');
|
||||||
|
|
||||||
|
// Set access auth.
|
||||||
|
await page.getByTestId('documentAccessSelectValue').click();
|
||||||
|
await page.getByLabel('Require account').getByText('Require account').click();
|
||||||
|
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||||
|
|
||||||
|
// Action auth should NOT be visible.
|
||||||
|
await expect(page.getByTestId('documentActionSelectValue')).not.toBeVisible();
|
||||||
|
|
||||||
|
// 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]: title should be disabled depending on document status', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
const pendingDocument = await seedPendingDocument(user, []);
|
||||||
|
const draftDocument = await seedDraftDocument(user, []);
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/documents/${pendingDocument.id}/edit`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be disabled for pending documents.
|
||||||
|
await expect(page.getByLabel('Title')).toBeDisabled();
|
||||||
|
|
||||||
|
// Should be enabled for draft documents.
|
||||||
|
await page.goto(`/documents/${draftDocument.id}/edit`);
|
||||||
|
await expect(page.getByLabel('Title')).toBeEnabled();
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
118
packages/app-tests/e2e/document-flow/signers-step.spec.ts
Normal file
118
packages/app-tests/e2e/document-flow/signers-step.spec.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedUserSubscription } from '@documenso/prisma/seed/subscriptions';
|
||||||
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
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.
|
||||||
|
test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Advanced settings should not be visible for non EE users.
|
||||||
|
await expect(page.getByLabel('Show advanced settings')).toBeHidden();
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// // Expect that the advanced settings is unchecked, since no advanced settings were applied.
|
||||||
|
// await expect(page.getByLabel('Show advanced settings')).toBeChecked({ checked: false });
|
||||||
|
|
||||||
|
// // Add advanced settings for a single recipient.
|
||||||
|
// await page.getByLabel('Show advanced settings').click();
|
||||||
|
// await page.getByRole('combobox').first().click();
|
||||||
|
// await page.getByLabel('Require account').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();
|
||||||
|
|
||||||
|
// Expect that the advanced settings is visible, and the checkbox is hidden. Since advanced
|
||||||
|
// settings were applied.
|
||||||
|
|
||||||
|
// Todo: Fix stepper component back issue before finishing test.
|
||||||
|
|
||||||
|
await unseedUser(user.id);
|
||||||
|
});
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import type { Page } from '@playwright/test';
|
import { type Page } from '@playwright/test';
|
||||||
|
|
||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
type ManualLoginOptions = {
|
type LoginOptions = {
|
||||||
page: Page;
|
page: Page;
|
||||||
email?: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@ -18,7 +18,7 @@ export const manualLogin = async ({
|
|||||||
email = 'example@documenso.com',
|
email = 'example@documenso.com',
|
||||||
password = 'password',
|
password = 'password',
|
||||||
redirectPath,
|
redirectPath,
|
||||||
}: ManualLoginOptions) => {
|
}: LoginOptions) => {
|
||||||
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||||
|
|
||||||
await page.getByLabel('Email').click();
|
await page.getByLabel('Email').click();
|
||||||
@ -33,9 +33,63 @@ export const manualLogin = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const manualSignout = async ({ page }: ManualLoginOptions) => {
|
export const manualSignout = async ({ page }: LoginOptions) => {
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.getByTestId('menu-switcher').click();
|
await page.getByTestId('menu-switcher').click();
|
||||||
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
await page.getByRole('menuitem', { name: 'Sign Out' }).click();
|
||||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const apiSignin = async ({
|
||||||
|
page,
|
||||||
|
email = 'example@documenso.com',
|
||||||
|
password = 'password',
|
||||||
|
redirectPath = '/',
|
||||||
|
}: LoginOptions) => {
|
||||||
|
const { request } = page.context();
|
||||||
|
|
||||||
|
const csrfToken = await getCsrfToken(page);
|
||||||
|
|
||||||
|
await request.post(`${WEBAPP_BASE_URL}/api/auth/callback/credentials`, {
|
||||||
|
form: {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
json: true,
|
||||||
|
csrfToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (redirectPath) {
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}${redirectPath}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiSignout = async ({ page }: { page: Page }) => {
|
||||||
|
const { request } = page.context();
|
||||||
|
|
||||||
|
const csrfToken = await getCsrfToken(page);
|
||||||
|
|
||||||
|
await request.post(`${WEBAPP_BASE_URL}/api/auth/signout`, {
|
||||||
|
form: {
|
||||||
|
csrfToken,
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${WEBAPP_BASE_URL}/signin`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCsrfToken = async (page: Page) => {
|
||||||
|
const { request } = page.context();
|
||||||
|
|
||||||
|
const response = await request.fetch(`${WEBAPP_BASE_URL}/api/auth/csrf`, {
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { csrfToken } = await response.json();
|
||||||
|
if (!csrfToken) {
|
||||||
|
throw new Error('Invalid session');
|
||||||
|
}
|
||||||
|
|
||||||
|
return csrfToken;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,17 +1,36 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
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 { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from './fixtures/authentication';
|
||||||
|
|
||||||
|
const getDocumentByToken = async (token: string) => {
|
||||||
|
return await prisma.document.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
Recipient: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
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`;
|
||||||
|
|
||||||
// Sign in
|
const user = await seedUser();
|
||||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
|
||||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
|
await apiSignin({
|
||||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
page,
|
||||||
|
email: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
// Upload document
|
// Upload document
|
||||||
const [fileChooser] = await Promise.all([
|
const [fileChooser] = await Promise.all([
|
||||||
@ -28,8 +47,8 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
|||||||
// Wait to be redirected to the edit page
|
// Wait to be redirected to the edit page
|
||||||
await page.waitForURL(/\/documents\/\d+/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
|
||||||
// Set title
|
// Set general settings
|
||||||
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Title').fill(documentTitle);
|
await page.getByLabel('Title').fill(documentTitle);
|
||||||
|
|
||||||
@ -73,3 +92,267 @@ 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);
|
||||||
|
});
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|||||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
test('[TEAMS]: create team', async ({ page }) => {
|
test('[TEAMS]: create team', async ({ page }) => {
|
||||||
const user = await seedUser();
|
const user = await seedUser();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: '/settings/teams',
|
redirectPath: '/settings/teams',
|
||||||
@ -38,7 +38,7 @@ test('[TEAMS]: create team', async ({ page }) => {
|
|||||||
test('[TEAMS]: delete team', async ({ page }) => {
|
test('[TEAMS]: delete team', async ({ page }) => {
|
||||||
const team = await seedTeam();
|
const team = await seedTeam();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
redirectPath: `/t/${team.url}/settings`,
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
@ -56,7 +56,7 @@ test('[TEAMS]: delete team', async ({ page }) => {
|
|||||||
test('[TEAMS]: update team', async ({ page }) => {
|
test('[TEAMS]: update team', async ({ page }) => {
|
||||||
const team = await seedTeam();
|
const team = await seedTeam();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documen
|
|||||||
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
|
import { seedTeamEmail, unseedTeam, unseedTeamEmail } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { manualLogin, manualSignout } from '../fixtures/authentication';
|
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
|||||||
|
|
||||||
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
|
// Run the test twice, once with the team owner and once with a team member to ensure the counts are the same.
|
||||||
for (const user of [team.owner, teamMember2]) {
|
for (const user of [team.owner, teamMember2]) {
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
@ -55,7 +55,7 @@ test('[TEAMS]: check team documents count', async ({ page }) => {
|
|||||||
await checkDocumentTabCount(page, 'Draft', 1);
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
await checkDocumentTabCount(page, 'All', 3);
|
await checkDocumentTabCount(page, 'All', 3);
|
||||||
|
|
||||||
await manualSignout({ page });
|
await apiSignout({ page });
|
||||||
}
|
}
|
||||||
|
|
||||||
await unseedTeam(team.url);
|
await unseedTeam(team.url);
|
||||||
@ -126,7 +126,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
|||||||
|
|
||||||
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
// Run the test twice, one with the team owner and once with the team member email to ensure the counts are the same.
|
||||||
for (const user of [team.owner, teamEmailMember]) {
|
for (const user of [team.owner, teamEmailMember]) {
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
@ -151,7 +151,7 @@ test('[TEAMS]: check team documents count with internal team email', async ({ pa
|
|||||||
await checkDocumentTabCount(page, 'Draft', 1);
|
await checkDocumentTabCount(page, 'Draft', 1);
|
||||||
await checkDocumentTabCount(page, 'All', 3);
|
await checkDocumentTabCount(page, 'All', 3);
|
||||||
|
|
||||||
await manualSignout({ page });
|
await apiSignout({ page });
|
||||||
}
|
}
|
||||||
|
|
||||||
await unseedTeamEmail({ teamId: team.id });
|
await unseedTeamEmail({ teamId: team.id });
|
||||||
@ -216,7 +216,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: teamMember2.email,
|
email: teamMember2.email,
|
||||||
redirectPath: `/t/${team.url}/documents`,
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
@ -248,7 +248,7 @@ test('[TEAMS]: check team documents count with external team email', async ({ pa
|
|||||||
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
test('[TEAMS]: delete pending team document', async ({ page }) => {
|
||||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: currentUser.email,
|
email: currentUser.email,
|
||||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||||
@ -266,7 +266,7 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
|
|||||||
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||||
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
const { team, teamMember2: currentUser } = await seedTeamDocuments();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: currentUser.email,
|
email: currentUser.email,
|
||||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|||||||
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamEmailVerification, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
test('[TEAMS]: send team email request', async ({ page }) => {
|
test('[TEAMS]: send team email request', async ({ page }) => {
|
||||||
const team = await seedTeam();
|
const team = await seedTeam();
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
@ -57,7 +57,7 @@ test('[TEAMS]: delete team email', async ({ page }) => {
|
|||||||
createTeamEmail: true,
|
createTeamEmail: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
redirectPath: `/t/${team.url}/settings`,
|
redirectPath: `/t/${team.url}/settings`,
|
||||||
@ -86,7 +86,7 @@ test('[TEAMS]: team email owner removes access', async ({ page }) => {
|
|||||||
email: team.teamEmail.email,
|
email: team.teamEmail.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: teamEmailOwner.email,
|
email: teamEmailOwner.email,
|
||||||
redirectPath: `/settings/teams`,
|
redirectPath: `/settings/teams`,
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|||||||
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamInvite, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ test('[TEAMS]: update team member role', async ({ page }) => {
|
|||||||
createTeamMembers: 1,
|
createTeamMembers: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
@ -75,7 +75,7 @@ test('[TEAMS]: member can leave team', async ({ page }) => {
|
|||||||
|
|
||||||
const teamMember = team.members[1];
|
const teamMember = team.members[1];
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: teamMember.user.email,
|
email: teamMember.user.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
@ -97,7 +97,7 @@ test('[TEAMS]: owner cannot leave team', async ({ page }) => {
|
|||||||
createTeamMembers: 1,
|
createTeamMembers: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
||||||
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, seedTeamTransfer, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ test('[TEAMS]: initiate and cancel team transfer', async ({ page }) => {
|
|||||||
|
|
||||||
const teamMember = team.members[1];
|
const teamMember = team.members[1];
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: team.owner.email,
|
email: team.owner.email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app';
|
|||||||
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam, unseedTeam } from '@documenso/prisma/seed/teams';
|
||||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||||
|
|
||||||
import { manualLogin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
test.describe.configure({ mode: 'parallel' });
|
test.describe.configure({ mode: 'parallel' });
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: '/templates',
|
redirectPath: '/templates',
|
||||||
@ -81,7 +81,7 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: '/templates',
|
redirectPath: '/templates',
|
||||||
@ -135,7 +135,7 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: '/templates',
|
redirectPath: '/templates',
|
||||||
@ -181,7 +181,7 @@ test('[TEMPLATES]: use template', async ({ page }) => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manualLogin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
redirectPath: '/templates',
|
redirectPath: '/templates',
|
||||||
|
|||||||
37
packages/app-tests/e2e/test-update-user-name.spec.ts
Normal file
37
packages/app-tests/e2e/test-update-user-name.spec.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { manualLogin } from './fixtures/authentication';
|
||||||
|
|
||||||
|
test('update user name', async ({ page }) => {
|
||||||
|
const user = await seedUser();
|
||||||
|
|
||||||
|
await manualLogin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: '/settings/profile',
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByLabel('Full Name').fill('John Doe');
|
||||||
|
|
||||||
|
const canvas = page.locator('canvas');
|
||||||
|
const box = await canvas.boundingBox();
|
||||||
|
|
||||||
|
if (box) {
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||||
|
await page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update profile' }).click();
|
||||||
|
|
||||||
|
// wait for it to finish
|
||||||
|
await expect(page.getByText('Profile updated', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.waitForURL('/settings/profile');
|
||||||
|
|
||||||
|
expect((await getUserByEmail({ email: user.email })).name).toEqual('John Doe');
|
||||||
|
});
|
||||||
@ -6,6 +6,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:dev": "playwright test",
|
"test:dev": "playwright test",
|
||||||
|
"test-ui:dev": "playwright test --ui",
|
||||||
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
|
"test:e2e": "start-server-and-test \"npm run start -w @documenso/web\" http://localhost:3000 \"playwright test\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
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.
|
|
||||||
* https://github.com/motdotla/dotenv
|
ENV_FILES.forEach((file) => {
|
||||||
*/
|
dotenv.config({
|
||||||
// require('dotenv').config();
|
path: path.join(__dirname, `../../${file}`),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
|||||||
56
packages/ee/server-only/util/is-document-enterprise.ts
Normal file
56
packages/ee/server-only/util/is-document-enterprise.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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);
|
||||||
|
};
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import { Section, Text } from '../components';
|
||||||
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
|
export interface TemplateDocumentDeleteProps {
|
||||||
|
reason: string;
|
||||||
|
documentName: string;
|
||||||
|
assetBaseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateDocumentDelete = ({
|
||||||
|
reason,
|
||||||
|
documentName,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: TemplateDocumentDeleteProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Text className="text-primary mb-0 mt-6 text-left text-lg font-semibold">
|
||||||
|
Your document has been deleted by an admin!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mx-auto mb-6 mt-1 text-left text-base text-slate-400">
|
||||||
|
"{documentName}" has been deleted by an admin.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mx-auto mb-6 mt-1 text-left text-base text-slate-400">
|
||||||
|
This document can not be recovered, if you would like to dispute the reason for future
|
||||||
|
documents please contact support.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mx-auto mt-1 text-left text-base text-slate-400">
|
||||||
|
The reason provided for deletion is the following:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="mx-auto mb-6 mt-1 text-left text-base italic text-slate-400">
|
||||||
|
{reason}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TemplateDocumentDelete;
|
||||||
66
packages/email/templates/document-super-delete.tsx
Normal file
66
packages/email/templates/document-super-delete.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import { Body, Container, Head, Hr, Html, Img, Preview, Section, Tailwind } from '../components';
|
||||||
|
import {
|
||||||
|
TemplateDocumentDelete,
|
||||||
|
type TemplateDocumentDeleteProps,
|
||||||
|
} from '../template-components/template-document-super-delete';
|
||||||
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
|
export type DocumentDeleteEmailTemplateProps = Partial<TemplateDocumentDeleteProps>;
|
||||||
|
|
||||||
|
export const DocumentSuperDeleteEmailTemplate = ({
|
||||||
|
documentName = 'Open Source Pledge.pdf',
|
||||||
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
|
reason = 'Unknown',
|
||||||
|
}: DocumentDeleteEmailTemplateProps) => {
|
||||||
|
const previewText = `An admin has deleted your document "${documentName}".`;
|
||||||
|
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
|
<Section>
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
|
<Section>
|
||||||
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateDocumentDelete
|
||||||
|
reason={reason}
|
||||||
|
documentName={documentName}
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Hr className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentSuperDeleteEmailTemplate;
|
||||||
@ -4,16 +4,15 @@ module.exports = {
|
|||||||
'turbo',
|
'turbo',
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:prettier/recommended',
|
|
||||||
'plugin:package-json/recommended',
|
'plugin:package-json/recommended',
|
||||||
],
|
],
|
||||||
|
|
||||||
plugins: ['prettier', 'package-json', 'unused-imports'],
|
plugins: ['package-json', 'unused-imports'],
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
|
es2022: true,
|
||||||
node: true,
|
node: true,
|
||||||
browser: true,
|
browser: true,
|
||||||
es6: true,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
|
|||||||
@ -7,16 +7,14 @@
|
|||||||
"clean": "rimraf node_modules"
|
"clean": "rimraf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "6.8.0",
|
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||||
"@typescript-eslint/parser": "6.8.0",
|
"@typescript-eslint/parser": "^7.1.1",
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "13.4.19",
|
"eslint-config-next": "^14.1.3",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-turbo": "^1.12.5",
|
||||||
"eslint-config-turbo": "^1.9.3",
|
"eslint-plugin-package-json": "^0.10.4",
|
||||||
"eslint-plugin-package-json": "^0.2.0",
|
"eslint-plugin-react": "^7.34.0",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-unused-imports": "^3.1.0",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
|
||||||
"eslint-plugin-unused-imports": "^3.0.0",
|
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,10 +16,24 @@ export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: s
|
|||||||
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
||||||
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
||||||
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
||||||
|
[UserSecurityAuditLogType.PASSKEY_CREATED]: 'Passkey created',
|
||||||
|
[UserSecurityAuditLogType.PASSKEY_DELETED]: 'Passkey deleted',
|
||||||
|
[UserSecurityAuditLogType.PASSKEY_UPDATED]: 'Passkey updated',
|
||||||
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
||||||
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
||||||
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
||||||
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
||||||
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
||||||
|
[UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL]: 'Passkey sign in failed',
|
||||||
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The duration to wait for a passkey to be verified in MS.
|
||||||
|
*/
|
||||||
|
export const PASSKEY_TIMEOUT = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of passkeys are user can have.
|
||||||
|
*/
|
||||||
|
export const MAXIMUM_PASSKEYS = 50;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
26
packages/lib/constants/document-auth.ts
Normal file
26
packages/lib/constants/document-auth.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { TDocumentAuth } from '../types/document-auth';
|
||||||
|
import { DocumentAuth } from '../types/document-auth';
|
||||||
|
|
||||||
|
type DocumentAuthTypeData = {
|
||||||
|
key: TDocumentAuth;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
|
||||||
|
[DocumentAuth.ACCOUNT]: {
|
||||||
|
key: DocumentAuth.ACCOUNT,
|
||||||
|
value: 'Require account',
|
||||||
|
},
|
||||||
|
[DocumentAuth.PASSKEY]: {
|
||||||
|
key: DocumentAuth.PASSKEY,
|
||||||
|
value: 'Require passkey',
|
||||||
|
},
|
||||||
|
[DocumentAuth.TWO_FACTOR_AUTH]: {
|
||||||
|
key: DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
|
value: 'Require 2FA',
|
||||||
|
},
|
||||||
|
[DocumentAuth.EXPLICIT_NONE]: {
|
||||||
|
key: DocumentAuth.EXPLICIT_NONE,
|
||||||
|
value: 'None (Overrides global settings)',
|
||||||
|
},
|
||||||
|
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { env } from 'next-runtime-env';
|
import { env } from 'next-runtime-env';
|
||||||
|
|
||||||
import { APP_BASE_URL } from './app';
|
import { APP_BASE_URL, WEBAPP_BASE_URL } from './app';
|
||||||
|
|
||||||
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
|
const NEXT_PUBLIC_FEATURE_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED');
|
||||||
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
const NEXT_PUBLIC_POSTHOG_KEY = () => env('NEXT_PUBLIC_POSTHOG_KEY');
|
||||||
@ -23,6 +23,7 @@ export const FEATURE_FLAG_POLL_INTERVAL = 30000;
|
|||||||
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
export const LOCAL_FEATURE_FLAGS: Record<string, boolean> = {
|
||||||
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
app_billing: NEXT_PUBLIC_FEATURE_BILLING_ENABLED() === 'true',
|
||||||
app_document_page_view_history_sheet: false,
|
app_document_page_view_history_sheet: false,
|
||||||
|
app_passkey: WEBAPP_BASE_URL === 'http://localhost:3000', // Temp feature flag.
|
||||||
marketing_header_single_player_mode: false,
|
marketing_header_single_player_mode: false,
|
||||||
marketing_profiles_announcement_bar: true,
|
marketing_profiles_announcement_bar: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
25
packages/lib/constants/trpc.ts
Normal file
25
packages/lib/constants/trpc.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -137,6 +137,7 @@ export class AppError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static parseFromJSONString(jsonString: string): AppError | null {
|
static parseFromJSONString(jsonString: string): AppError | null {
|
||||||
|
try {
|
||||||
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
|
const parsed = ZAppErrorJsonSchema.safeParse(JSON.parse(jsonString));
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@ -144,5 +145,8 @@ export class AppError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
|
return new AppError(parsed.data.code, parsed.data.message, parsed.data.userMessage);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
/// <reference types="../types/next-auth.d.ts" />
|
/// <reference types="../types/next-auth.d.ts" />
|
||||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||||
import { compare } from '@node-rs/bcrypt';
|
import { compare } from '@node-rs/bcrypt';
|
||||||
|
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { AuthOptions, Session, User } from 'next-auth';
|
import type { AuthOptions, Session, User } from 'next-auth';
|
||||||
import type { JWT } from 'next-auth/jwt';
|
import type { JWT } from 'next-auth/jwt';
|
||||||
@ -12,12 +13,16 @@ import { env } from 'next-runtime-env';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||||
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
import { getMostRecentVerificationTokenByUserId } from '../server-only/user/get-most-recent-verification-token-by-user-id';
|
||||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
import { sendConfirmationToken } from '../server-only/user/send-confirmation-token';
|
||||||
|
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||||
|
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||||
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||||
|
import { getAuthenticatorOptions } from '../utils/authenticator';
|
||||||
import { ErrorCode } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||||
@ -131,6 +136,113 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
CredentialsProvider({
|
||||||
|
id: 'webauthn',
|
||||||
|
name: 'Keypass',
|
||||||
|
credentials: {
|
||||||
|
csrfToken: { label: 'csrfToken', type: 'csrfToken' },
|
||||||
|
},
|
||||||
|
async authorize(credentials, req) {
|
||||||
|
const csrfToken = credentials?.csrfToken;
|
||||||
|
|
||||||
|
if (typeof csrfToken !== 'string' || csrfToken.length === 0) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestBodyCrediential: TAuthenticationResponseJSONSchema | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedBodyCredential = JSON.parse(req.body?.credential);
|
||||||
|
requestBodyCrediential = ZAuthenticationResponseJSONSchema.parse(parsedBodyCredential);
|
||||||
|
} catch {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
const challengeToken = await prisma.anonymousVerificationToken
|
||||||
|
.delete({
|
||||||
|
where: {
|
||||||
|
id: csrfToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!challengeToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (challengeToken.expiresAt < new Date()) {
|
||||||
|
throw new AppError(AppErrorCode.EXPIRED_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passkey = await prisma.passkey.findFirst({
|
||||||
|
where: {
|
||||||
|
credentialId: Buffer.from(requestBodyCrediential.id, 'base64'),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
User: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passkey) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_SETUP);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = passkey.User;
|
||||||
|
|
||||||
|
const { rpId, origin } = getAuthenticatorOptions();
|
||||||
|
|
||||||
|
const verification = await verifyAuthenticationResponse({
|
||||||
|
response: requestBodyCrediential,
|
||||||
|
expectedChallenge: challengeToken.token,
|
||||||
|
expectedOrigin: origin,
|
||||||
|
expectedRPID: rpId,
|
||||||
|
authenticator: {
|
||||||
|
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
|
||||||
|
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||||
|
counter: Number(passkey.counter),
|
||||||
|
},
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
const requestMetadata = extractNextAuthRequestMetadata(req);
|
||||||
|
|
||||||
|
if (!verification?.verified) {
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: requestMetadata.ipAddress,
|
||||||
|
userAgent: requestMetadata.userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.passkey.update({
|
||||||
|
where: {
|
||||||
|
id: passkey.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
counter: verification.authenticationInfo.newCounter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(user.id),
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
emailVerified: user.emailVerified?.toISOString() ?? null,
|
||||||
|
} satisfies User;
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user, trigger, account }) {
|
async jwt({ token, user, trigger, account }) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user