mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 20:42:34 +10:00
Compare commits
83 Commits
feat/docum
...
v1.5.4-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
# Start the database and mailserver
|
||||
docker compose -f ./docker/compose-without-app.yml up -d
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Copy the env file
|
||||
cp .env.example .env
|
||||
|
||||
# Run the migrations
|
||||
npm run prisma:migrate-dev
|
||||
# Run the dev setup
|
||||
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.
|
||||
NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
|
||||
|
||||
# [[E2E Tests]]
|
||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||
# [[SIGNING]]
|
||||
# The transport to use for document signing. Available options: local (default) | gcloud-hsm
|
||||
NEXT_PRIVATE_SIGNING_TRANSPORT="local"
|
||||
# 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]]
|
||||
# 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"
|
||||
# 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"
|
||||
# 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.
|
||||
NEXT_PRIVATE_UPLOAD_REGION="unknown"
|
||||
# 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_WEBHOOK_SECRET=
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
|
||||
NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID=
|
||||
|
||||
# [[FEATURES]]
|
||||
# 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.
|
||||
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
|
||||
# [[REDIS]]
|
||||
NEXT_PRIVATE_REDIS_URL=
|
||||
|
||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -1,7 +1,9 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"],
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
|
||||
@ -21,7 +21,7 @@ const FONT_CAVEAT_BYTES = fs.readFileSync(
|
||||
const config = {
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt'],
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@ -38,7 +38,8 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
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
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
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> & {
|
||||
data: T;
|
||||
@ -34,13 +33,13 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)} {...props}>
|
||||
<div className="flex items-center px-4">
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<span>{extraInfo}</span>
|
||||
</div>
|
||||
<div className={className} {...props}>
|
||||
<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>
|
||||
<span>{extraInfo}</span>
|
||||
</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}>
|
||||
<BarChart data={formattedData}>
|
||||
<XAxis dataKey="month" />
|
||||
|
||||
@ -5,8 +5,6 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { Cell, Legend, Pie, PieChart, Tooltip } from 'recharts';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { CAP_TABLE } from './data';
|
||||
|
||||
const COLORS = ['#7fd843', '#a2e771', '#c6f2a4'];
|
||||
@ -49,10 +47,12 @@ export const CapTable = ({ className, ...props }: CapTableProps) => {
|
||||
setIsSSR(false);
|
||||
}, []);
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)} {...props}>
|
||||
<h3 className="px-4 text-lg font-semibold">Cap Table</h3>
|
||||
<div className={className} {...props}>
|
||||
<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 && (
|
||||
<PieChart width={400} height={400}>
|
||||
<Pie
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { formatMonth } from '@documenso/lib/client-only/format-month';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type FundingRaisedProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: Record<string, string | number>[];
|
||||
@ -18,10 +17,12 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)} {...props}>
|
||||
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
|
||||
<div className={className} {...props}>
|
||||
<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}>
|
||||
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
|
||||
<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 type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type MonthlyNewUsersChartProps = {
|
||||
className?: string;
|
||||
@ -20,12 +19,12 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<div className="flex items-center px-4">
|
||||
<h3 className="text-lg font-semibold">New Users</h3>
|
||||
</div>
|
||||
<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">New Users</h3>
|
||||
</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}>
|
||||
<BarChart data={formattedData}>
|
||||
<XAxis dataKey="month" />
|
||||
|
||||
@ -4,7 +4,6 @@ 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';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
export type MonthlyTotalUsersChartProps = {
|
||||
className?: string;
|
||||
@ -20,12 +19,12 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<div className="flex items-center px-4">
|
||||
<h3 className="text-lg font-semibold">Total Users</h3>
|
||||
</div>
|
||||
<div className={className}>
|
||||
<div className="border-border flex flex-1 flex-col justify-center rounded-2xl border p-6 pl-2 shadow-sm hover:shadow">
|
||||
<div className="mb-6 flex px-4">
|
||||
<h3 className="text-lg font-semibold">Total Users</h3>
|
||||
</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}>
|
||||
<BarChart data={formattedData}>
|
||||
<XAxis dataKey="month" />
|
||||
|
||||
@ -2,20 +2,23 @@ import type { Metadata } from 'next';
|
||||
|
||||
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 { 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 { BarMetric } from './bar-metrics';
|
||||
import { CapTable } from './cap-table';
|
||||
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 { MonthlyTotalUsersChart } from './monthly-total-users-chart';
|
||||
import { SalaryBands } from './salary-bands';
|
||||
import { TeamMembers } from './team-members';
|
||||
import { OpenPageTooltip } from './tooltip';
|
||||
import { TotalSignedDocumentsChart } from './total-signed-documents-chart';
|
||||
import { Typefully } from './typefully';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -131,16 +134,18 @@ export default async function OpenPage() {
|
||||
{ total_count: mergedPullRequests },
|
||||
STARGAZERS_DATA,
|
||||
EARLY_ADOPTERS_DATA,
|
||||
MONTHLY_USERS,
|
||||
MONTHLY_COMPLETED_DOCUMENTS,
|
||||
] = await Promise.all([
|
||||
fetchGithubStats(),
|
||||
fetchOpenIssues(),
|
||||
fetchMergedPullRequests(),
|
||||
fetchStargazers(),
|
||||
fetchEarlyAdopters(),
|
||||
getUserMonthlyGrowth(),
|
||||
getCompletedDocumentsMonthly(),
|
||||
]);
|
||||
|
||||
const MONTHLY_USERS = await getUserMonthlyGrowth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
||||
@ -161,7 +166,7 @@ export default async function OpenPage() {
|
||||
</p>
|
||||
</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">
|
||||
<MetricCard
|
||||
className="col-span-2 lg:col-span-1"
|
||||
@ -188,11 +193,57 @@ export default async function OpenPage() {
|
||||
<TeamMembers 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" />
|
||||
|
||||
<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>
|
||||
data={EARLY_ADOPTERS_DATA}
|
||||
metricKey="earlyAdopters"
|
||||
@ -202,57 +253,29 @@ export default async function OpenPage() {
|
||||
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" />
|
||||
<MonthlyNewUsersChart data={MONTHLY_USERS} className="col-span-12 lg:col-span-6" />
|
||||
|
||||
<Typefully className="col-span-12 lg:col-span-6" />
|
||||
|
||||
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
||||
<h2 className="text-2xl font-bold">Where's the rest?</h2>
|
||||
|
||||
<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
|
||||
soon as we have more to share.
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
<h2 className="text-2xl font-bold">Is there more?</h2>
|
||||
|
||||
<p className="text-muted-foreground mt-4 max-w-[55ch] text-center text-lg leading-normal">
|
||||
This page is evolving as we learn what makes a great signing company. We'll update it when
|
||||
we have more to share.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CallToAction className="mt-12" utmSource="open-page" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
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 { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type TypefullyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Typefully = ({ className, ...props }: TypefullyProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)} {...props}>
|
||||
<h3 className="px-4 text-lg font-semibold">Twitter Stats</h3>
|
||||
<div className={className} {...props}>
|
||||
<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="flex flex-col items-center gap-y-4 text-center">
|
||||
<div className="my-12 flex flex-col items-center gap-y-4 text-center">
|
||||
<FaXTwitter className="h-12 w-12" />
|
||||
<Link href="https://typefully.com/documenso/stats" target="_blank">
|
||||
<h1>Documenso on X</h1>
|
||||
|
||||
@ -247,6 +247,7 @@ export const SinglePlayerClient = () => {
|
||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||
fields={fields}
|
||||
onSubmit={onFieldsSubmit}
|
||||
isDocumentPdfLoaded={true}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@ export const Callout = ({ starCount }: CalloutProps) => {
|
||||
>
|
||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||
<LuGithub className="mr-2 h-5 w-5" />
|
||||
Star on Github
|
||||
Star on GitHub
|
||||
{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">
|
||||
{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')}>
|
||||
<Button variant="outline" className="rounded-full bg-transparent backdrop-blur-sm">
|
||||
<LuGithub className="mr-2 h-5 w-5" />
|
||||
Star on Github
|
||||
Star on GitHub
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
@ -23,7 +23,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<motion.button
|
||||
key="MONTHLY"
|
||||
@ -40,7 +40,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
{period === 'MONTHLY' && (
|
||||
<motion.div
|
||||
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>
|
||||
@ -63,7 +63,7 @@ export const PricingTable = ({ className, ...props }: PricingTableProps) => {
|
||||
{period === 'YEARLY' && (
|
||||
<motion.div
|
||||
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>
|
||||
|
||||
@ -22,7 +22,7 @@ const config = {
|
||||
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, '../../'),
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt'],
|
||||
serverComponentsExternalPackages: ['@node-rs/bcrypt', '@documenso/pdf-sign'],
|
||||
serverActions: {
|
||||
bodySizeLimit: '50mb',
|
||||
},
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"@tanstack/react-query": "^4.29.5",
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
@ -70,4 +71,4 @@
|
||||
"next": "$next"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
|
||||
import { AdminActions } from './admin-actions';
|
||||
import { RecipientItem } from './recipient-item';
|
||||
import { SuperDeleteDocumentDialog } from './super-delete-document-dialog';
|
||||
|
||||
type AdminDocumentDetailsPageProps = {
|
||||
params: {
|
||||
@ -81,6 +82,10 @@ export default async function AdminDocumentDetailsPage({ params }: AdminDocument
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
{document && <SuperDeleteDocumentDialog document={document} />}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -1,17 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
import {
|
||||
type DocumentData,
|
||||
type DocumentMeta,
|
||||
type Field,
|
||||
type Recipient,
|
||||
type User,
|
||||
} from '@documenso/prisma/client';
|
||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
SKIP_QUERY_BATCH_META,
|
||||
} from '@documenso/lib/constants/trpc';
|
||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -33,13 +30,9 @@ import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type EditDocumentFormProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
document: DocumentWithData;
|
||||
recipients: Recipient[];
|
||||
documentMeta: DocumentMeta | null;
|
||||
fields: Field[];
|
||||
documentData: DocumentData;
|
||||
initialDocument: DocumentWithDetails;
|
||||
documentRootPath: string;
|
||||
isDocumentEnterprise: boolean;
|
||||
};
|
||||
|
||||
type EditDocumentStep = 'settings' | 'signers' | 'fields' | 'subject';
|
||||
@ -47,13 +40,9 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
|
||||
|
||||
export const EditDocumentForm = ({
|
||||
className,
|
||||
document,
|
||||
recipients,
|
||||
fields,
|
||||
documentMeta,
|
||||
user: _user,
|
||||
documentData,
|
||||
initialDocument,
|
||||
documentRootPath,
|
||||
isDocumentEnterprise,
|
||||
}: EditDocumentFormProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
@ -61,11 +50,76 @@ export const EditDocumentForm = ({
|
||||
const searchParams = useSearchParams();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { mutateAsync: setSettingsForDocument } =
|
||||
trpc.document.setSettingsForDocument.useMutation();
|
||||
const { mutateAsync: addFields } = trpc.field.addFields.useMutation();
|
||||
const { mutateAsync: addSigners } = trpc.recipient.addSigners.useMutation();
|
||||
const { mutateAsync: sendDocument } = trpc.document.sendDocument.useMutation();
|
||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
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 } =
|
||||
trpc.document.setPasswordForDocument.useMutation();
|
||||
|
||||
@ -128,6 +182,7 @@ export const EditDocumentForm = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('signers');
|
||||
@ -136,7 +191,7 @@ export const EditDocumentForm = ({
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while updating the general settings.',
|
||||
description: 'An error occurred while updating the document settings.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
@ -144,7 +199,6 @@ export const EditDocumentForm = ({
|
||||
|
||||
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addSigners({
|
||||
documentId: document.id,
|
||||
teamId: team?.id,
|
||||
@ -155,7 +209,9 @@ export const EditDocumentForm = ({
|
||||
})),
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('fields');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -170,13 +226,14 @@ export const EditDocumentForm = ({
|
||||
|
||||
const onAddFieldsFormSubmit = async (data: TAddFieldsFormSchema) => {
|
||||
try {
|
||||
// Custom invocation server action
|
||||
await addFields({
|
||||
documentId: document.id,
|
||||
fields: data.fields,
|
||||
});
|
||||
|
||||
// Router refresh is here to clear the router cache for when navigating to /documents.
|
||||
router.refresh();
|
||||
|
||||
setStep('subject');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -229,6 +286,15 @@ export const EditDocumentForm = ({
|
||||
|
||||
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 (
|
||||
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
|
||||
<Card
|
||||
@ -237,11 +303,12 @@ export const EditDocumentForm = ({
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={documentData.id}
|
||||
documentData={documentData}
|
||||
key={document.documentData.id}
|
||||
documentData={document.documentData}
|
||||
document={document}
|
||||
password={documentMeta?.password}
|
||||
password={document.documentMeta?.password}
|
||||
onPasswordSubmit={onPasswordSubmit}
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -261,15 +328,18 @@ export const EditDocumentForm = ({
|
||||
document={document}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSubmit={onAddSettingsFormSubmit}
|
||||
/>
|
||||
|
||||
<AddSignersFormPartial
|
||||
key={recipients.length}
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddFieldsFormPartial
|
||||
@ -278,6 +348,7 @@ export const EditDocumentForm = ({
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddFieldsFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
|
||||
<AddSubjectFormPartial
|
||||
@ -287,6 +358,7 @@ export const EditDocumentForm = ({
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
onSubmit={onAddSubjectFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
|
||||
@ -3,11 +3,10 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
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 { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import type { Team } from '@documenso/prisma/client';
|
||||
@ -37,13 +36,18 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
const document = await getDocumentById({
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
const document = await getDocumentWithDetailsById({
|
||||
id: documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document || !document.documentData) {
|
||||
if (!document) {
|
||||
redirect(documentRootPath);
|
||||
}
|
||||
|
||||
@ -51,7 +55,7 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
redirect(`${documentRootPath}/${documentId}`);
|
||||
}
|
||||
|
||||
const { documentData, documentMeta } = document;
|
||||
const { documentMeta, Recipient: recipients } = document;
|
||||
|
||||
if (documentMeta?.password) {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
@ -70,18 +74,6 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
documentMeta.password = securePassword;
|
||||
}
|
||||
|
||||
const [recipients, fields] = await Promise.all([
|
||||
getRecipientsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
}),
|
||||
getFieldsForDocument({
|
||||
documentId,
|
||||
userId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -109,13 +101,9 @@ export const DocumentEditPageView = async ({ params, team }: DocumentEditPageVie
|
||||
|
||||
<EditDocumentForm
|
||||
className="mt-8"
|
||||
document={document}
|
||||
user={user}
|
||||
documentMeta={documentMeta}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
documentData={documentData}
|
||||
initialDocument={document}
|
||||
documentRootPath={documentRootPath}
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||
import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog';
|
||||
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';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -28,76 +28,70 @@ export default async function SecuritySettingsPage() {
|
||||
subtitle="Here you can manage your password and security settings."
|
||||
/>
|
||||
|
||||
{user.identityProvider === 'DOCUMENSO' ? (
|
||||
<div>
|
||||
{user.identityProvider === 'DOCUMENSO' && (
|
||||
<>
|
||||
<PasswordForm user={user} />
|
||||
|
||||
<hr className="border-border/50 mt-6" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Two factor authentication</AlertTitle>
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Two factor authentication</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Create one-time passwords that serve as a secondary authentication method for
|
||||
confirming your identity when requested during the sign-in process.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</Alert>
|
||||
|
||||
{isPasskeyEnabled && (
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Passkeys</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline" className="bg-background">
|
||||
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Recovery codes</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Two factor authentication recovery codes are used to access your account in the
|
||||
event that you lose access to your authenticator app.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Alert className="p-6" variant="neutral">
|
||||
<AlertTitle>
|
||||
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
To update your password, enable two-factor authentication, and manage other security
|
||||
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
||||
settings.
|
||||
<AlertDescription className="mr-4">
|
||||
Add an authenticator to serve as a secondary authentication method{' '}
|
||||
{user.identityProvider === 'DOCUMENSO'
|
||||
? 'when signing in, or when signing documents.'
|
||||
: 'for signing documents.'}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{user.twoFactorEnabled ? (
|
||||
<DisableAuthenticatorAppDialog />
|
||||
) : (
|
||||
<EnableAuthenticatorAppDialog />
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{user.twoFactorEnabled && (
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Recovery codes</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Two factor authentication recovery codes are used to access your account in the event
|
||||
that you lose access to your authenticator app.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<ViewRecoveryCodesDialog />
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isPasskeyEnabled && (
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>Passkeys</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-4">
|
||||
Allows authenticating using biometrics, password managers, hardware keys, etc.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline" className="bg-background">
|
||||
<Link href="/settings/security/passkeys">Manage passkeys</Link>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
@ -38,7 +38,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type CreatePasskeyDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
onSuccess?: () => void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreatePasskeyFormSchema = z.object({
|
||||
@ -49,7 +48,7 @@ type TCreatePasskeyFormSchema = z.infer<typeof ZCreatePasskeyFormSchema>;
|
||||
|
||||
const parser = new UAParser();
|
||||
|
||||
export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePasskeyDialogProps) => {
|
||||
export const CreatePasskeyDialog = ({ trigger, ...props }: CreatePasskeyDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
@ -85,7 +84,6 @@ export const CreatePasskeyDialog = ({ trigger, onSuccess, ...props }: CreatePass
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
if (err.name === 'NotAllowedError') {
|
||||
|
||||
@ -172,29 +172,27 @@ export const UserPasskeysDataTableActions = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
void deletePasskey({
|
||||
passkeyId,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isDeletingPasskey}>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" variant="destructive" loading={isDeletingPasskey}>
|
||||
Delete
|
||||
<fieldset disabled={isDeletingPasskey}>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
onClick={async () =>
|
||||
deletePasskey({
|
||||
passkeyId,
|
||||
})
|
||||
}
|
||||
variant="destructive"
|
||||
loading={isDeletingPasskey}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
convertToLocalSystemFormat,
|
||||
} from '@documenso/lib/constants/date-formats';
|
||||
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';
|
||||
@ -40,12 +41,12 @@ export const DateField = ({
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
|
||||
@ -1,161 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
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 { 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 [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
|
||||
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth['2FA'],
|
||||
token,
|
||||
});
|
||||
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
|
||||
const error = AppError.parseError(err);
|
||||
setFormErrorCode(error.code);
|
||||
|
||||
// Todo: Alert.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
token: '',
|
||||
});
|
||||
|
||||
setFormErrorCode(null);
|
||||
}, [open, form]);
|
||||
|
||||
if (!user?.twoFactorEnabled) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
{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()}.`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
<Button type="button" asChild>
|
||||
<Link href="/settings/security">Setup 2FA</Link>
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@ -1,83 +0,0 @@
|
||||
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>
|
||||
<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
|
||||
type="submit"
|
||||
onClick={async () => handleChangeAccount(recipient.email)}
|
||||
loading={isSigningOut}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
@ -1,31 +1,40 @@
|
||||
import { useMemo } from 'react';
|
||||
/**
|
||||
* Note: This file has some commented out stuff for password auth which is no longer possible.
|
||||
*
|
||||
* Leaving it here until after we add passkeys and 2FA since it can be reused.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { DateTime } from 'luxon';
|
||||
import { signOut } from 'next-auth/react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import {
|
||||
DocumentAuth,
|
||||
type TRecipientActionAuth,
|
||||
type TRecipientActionAuthTypes,
|
||||
} from '@documenso/lib/types/document-auth';
|
||||
import type { FieldType } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
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?: 'FIELD' | 'DOCUMENT';
|
||||
actionTarget: FieldType | 'DOCUMENT';
|
||||
isSubmitting?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
|
||||
@ -35,85 +44,197 @@ export type DocumentActionAuthDialogProps = {
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
};
|
||||
|
||||
// const ZReauthFormSchema = z.object({
|
||||
// password: ZCurrentPasswordSchema,
|
||||
// });
|
||||
// type TReauthFormSchema = z.infer<typeof ZReauthFormSchema>;
|
||||
|
||||
export const DocumentActionAuthDialog = ({
|
||||
title,
|
||||
description,
|
||||
documentAuthType,
|
||||
actionTarget = 'FIELD',
|
||||
// onReauthFormSubmit,
|
||||
isSubmitting,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReauthFormSubmit,
|
||||
}: DocumentActionAuthDialogProps) => {
|
||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentAuthContext();
|
||||
const { recipient } = useRequiredDocumentAuthContext();
|
||||
|
||||
// const form = useForm({
|
||||
// resolver: zodResolver(ZReauthFormSchema),
|
||||
// defaultValues: {
|
||||
// password: '',
|
||||
// },
|
||||
// });
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const isLoading = isSigningOut || isSubmitting; // || form.formState.isSubmitting;
|
||||
|
||||
const { mutateAsync: encryptSecondaryData } = trpc.crypto.encryptSecondaryData.useMutation();
|
||||
|
||||
// const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||
// const onFormSubmit = async (_values: TReauthFormSchema) => {
|
||||
// const documentAuthValue: TRecipientActionAuth = match(documentAuthType)
|
||||
// // Todo: Add passkey.
|
||||
// // .with(DocumentAuthType.PASSKEY, (type) => ({
|
||||
// // type,
|
||||
// // value,
|
||||
// // }))
|
||||
// .otherwise((type) => ({
|
||||
// type,
|
||||
// }));
|
||||
|
||||
// try {
|
||||
// await onReauthFormSubmit(documentAuthValue);
|
||||
|
||||
// onOpenChange(false);
|
||||
// } catch (e) {
|
||||
// const error = AppError.parseError(e);
|
||||
// setFormErrorCode(error.code);
|
||||
|
||||
// // Suppress unauthorized errors since it's handled in this component.
|
||||
// if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// throw error;
|
||||
// }
|
||||
// };
|
||||
|
||||
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.
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnOpenChange = (value: boolean) => {
|
||||
if (isCurrentlyAuthenticating) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange(value);
|
||||
};
|
||||
|
||||
const actionVerb =
|
||||
actionTarget === 'DOCUMENT' ? RECIPIENT_ROLES_DESCRIPTION[recipient.role].actionVerb : 'Sign';
|
||||
|
||||
const defaultTitleDescription = useMemo(() => {
|
||||
if (recipient.role === 'VIEWER' && actionTarget === 'DOCUMENT') {
|
||||
return {
|
||||
title: 'Mark document as viewed',
|
||||
description: 'Reauthentication is required to mark this document as viewed.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${actionVerb} ${actionTarget.toLowerCase()}`,
|
||||
description: `Reauthentication is required to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}`,
|
||||
};
|
||||
}, [recipient.role, actionVerb, actionTarget]);
|
||||
// useEffect(() => {
|
||||
// form.reset();
|
||||
// setFormErrorCode(null);
|
||||
// }, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOnOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title || defaultTitleDescription.title}</DialogTitle>
|
||||
<DialogTitle>{title || 'Sign field'}</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{description || defaultTitleDescription.description}
|
||||
{description || `Reauthentication is required to sign the field`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{match({ documentAuthType, user })
|
||||
.with(
|
||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auths requires them to be logged in.
|
||||
() => (
|
||||
<DocumentActionAuthAccount
|
||||
actionVerb={actionVerb}
|
||||
actionTarget={actionTarget}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
),
|
||||
)
|
||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||
<DocumentActionAuthPasskey
|
||||
actionTarget={actionTarget}
|
||||
actionVerb={actionVerb}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
{match(documentAuthType)
|
||||
.with(DocumentAuth.ACCOUNT, () => (
|
||||
<fieldset disabled={isSigningOut} className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
To sign this field, you need to be logged in as <strong>{recipient.email}</strong>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={async () => handleChangeAccount(recipient.email)}
|
||||
loading={isSigningOut}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth['2FA'] }, () => (
|
||||
<DocumentActionAuth2FA
|
||||
actionTarget={actionTarget}
|
||||
actionVerb={actionVerb}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||
.with(DocumentAuth.EXPLICIT_NONE, () => null)
|
||||
.exhaustive()}
|
||||
|
||||
{/* <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isLoading}>
|
||||
<FormItem>
|
||||
<FormLabel required>Email</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input className="bg-background" value={recipient.email} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Password</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PasswordInput className="bg-background" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{formErrorCode && (
|
||||
<Alert variant="destructive">
|
||||
{match(formErrorCode)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => (
|
||||
<>
|
||||
<AlertTitle>Unauthorized</AlertTitle>
|
||||
<AlertDescription>
|
||||
We were unable to verify your details. Please ensure the details are
|
||||
correct
|
||||
</AlertDescription>
|
||||
</>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<>
|
||||
<AlertTitle>Something went wrong</AlertTitle>
|
||||
<AlertDescription>
|
||||
We were unable to sign this field at this time. Please try again or
|
||||
contact support.
|
||||
</AlertDescription>
|
||||
</>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isLoading}>
|
||||
Sign field
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form> */}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -1,255 +0,0 @@
|
||||
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.isRefetching && 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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
|
||||
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
|
||||
import type {
|
||||
TDocumentAuthOptions,
|
||||
@ -14,19 +13,11 @@ import type {
|
||||
} 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, Passkey, Recipient, User } from '@documenso/prisma/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { type Document, FieldType, type Recipient, type User } from '@documenso/prisma/client';
|
||||
|
||||
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;
|
||||
@ -38,13 +29,7 @@ export type DocumentAuthContextValue = {
|
||||
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);
|
||||
@ -79,9 +64,6 @@ export const DocumentAuthProvider = ({
|
||||
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,
|
||||
@ -96,23 +78,6 @@ export const DocumentAuthProvider = ({
|
||||
[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);
|
||||
|
||||
@ -136,12 +101,12 @@ export const DocumentAuthProvider = ({
|
||||
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||
type: DocumentAuth.EXPLICIT_NONE,
|
||||
}))
|
||||
.with(DocumentAuth.PASSKEY, DocumentAuth['2FA'], null, () => null)
|
||||
.with(null, () => null)
|
||||
.exhaustive();
|
||||
|
||||
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||
// Directly run callback if no auth required.
|
||||
if (!derivedRecipientActionAuth) {
|
||||
if (!derivedRecipientActionAuth || options.actionTarget !== FieldType.SIGNATURE) {
|
||||
await options.onReauthFormSubmit();
|
||||
return;
|
||||
}
|
||||
@ -159,25 +124,11 @@ export const DocumentAuthProvider = ({
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const { passkeys } = passkeyData;
|
||||
|
||||
if (!preferredPasskeyId && passkeys.length > 0) {
|
||||
setPreferredPasskeyId(passkeys[0].id);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [passkeyData.passkeys]);
|
||||
|
||||
const isAuthRedirectRequired = Boolean(
|
||||
DOCUMENT_AUTH_TYPES[derivedRecipientActionAuth || '']?.isAuthRedirectRequired &&
|
||||
!preCalculatedActionAuthOptions,
|
||||
);
|
||||
|
||||
const refetchPasskeys = async () => {
|
||||
await passkeyQuery.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<DocumentAuthContext.Provider
|
||||
value={{
|
||||
@ -192,12 +143,6 @@ export const DocumentAuthProvider = ({
|
||||
derivedRecipientAccessAuth,
|
||||
derivedRecipientActionAuth,
|
||||
isAuthRedirectRequired,
|
||||
isCurrentlyAuthenticating,
|
||||
setIsCurrentlyAuthenticating,
|
||||
passkeyData,
|
||||
preferredPasskeyId,
|
||||
setPreferredPasskeyId,
|
||||
refetchPasskeys,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
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';
|
||||
@ -31,12 +32,12 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
|
||||
@ -20,7 +20,6 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
import { useRequiredSigningContext } from './provider';
|
||||
import { SignDialog } from './sign-dialog';
|
||||
|
||||
@ -37,7 +36,6 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentAuthContext();
|
||||
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
|
||||
@ -67,10 +65,13 @@ export const SigningForm = ({ document, recipient, fields, redirectUrl }: Signin
|
||||
return;
|
||||
}
|
||||
|
||||
await executeActionAuthProcedure({
|
||||
onReauthFormSubmit: completeDocument,
|
||||
actionTarget: 'DOCUMENT',
|
||||
});
|
||||
await completeDocument();
|
||||
|
||||
// Reauth is currently not required for completing the document.
|
||||
// await executeActionAuthProcedure({
|
||||
// onReauthFormSubmit: completeDocument,
|
||||
// actionTarget: 'DOCUMENT',
|
||||
// });
|
||||
};
|
||||
|
||||
const completeDocument = async (authOptions?: TRecipientActionAuth) => {
|
||||
|
||||
@ -6,9 +6,10 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
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 { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -39,12 +40,12 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
@ -69,6 +70,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
|
||||
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localFullName),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -12,8 +12,6 @@ import {
|
||||
|
||||
import { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||
|
||||
export type SignDialogProps = {
|
||||
isSubmitting: boolean;
|
||||
document: Document;
|
||||
@ -31,28 +29,26 @@ export const SignDialog = ({
|
||||
onSignatureComplete,
|
||||
role,
|
||||
}: SignDialogProps) => {
|
||||
const { executeActionAuthProcedure, isAuthRedirectRequired, isCurrentlyAuthenticating } =
|
||||
useRequiredDocumentAuthContext();
|
||||
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
const isComplete = fields.every((field) => field.inserted);
|
||||
|
||||
const handleOpenChange = async (open: boolean) => {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (isSubmitting || !isComplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthRedirectRequired) {
|
||||
await executeActionAuthProcedure({
|
||||
actionTarget: 'DOCUMENT',
|
||||
onReauthFormSubmit: () => {
|
||||
// Do nothing since the user should be redirected.
|
||||
},
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
// return;
|
||||
// }
|
||||
|
||||
setShowDialog(open);
|
||||
};
|
||||
@ -104,7 +100,7 @@ export const SignDialog = ({
|
||||
type="button"
|
||||
className="flex-1"
|
||||
disabled={!isComplete}
|
||||
loading={isSubmitting || isCurrentlyAuthenticating}
|
||||
loading={isSubmitting}
|
||||
onClick={onSignatureComplete}
|
||||
>
|
||||
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
||||
|
||||
@ -6,9 +6,10 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
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 { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -41,12 +42,12 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const { Signature: signature } = field;
|
||||
|
||||
@ -89,6 +90,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
|
||||
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
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 { FieldRootContainer } from '@documenso/ui/components/field/field';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
@ -55,11 +56,24 @@ export const SigningFieldContainer = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass reauth for non signature fields.
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
const presignResult = await onPreSign?.();
|
||||
|
||||
if (presignResult === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSign();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthRedirectRequired) {
|
||||
await executeActionAuthProcedure({
|
||||
onReauthFormSubmit: () => {
|
||||
// Do nothing since the user should be redirected.
|
||||
},
|
||||
actionTarget: field.type,
|
||||
});
|
||||
|
||||
return;
|
||||
@ -76,6 +90,7 @@ export const SigningFieldContainer = ({
|
||||
|
||||
await executeActionAuthProcedure({
|
||||
onReauthFormSubmit: onSign,
|
||||
actionTarget: field.type,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
|
||||
|
||||
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';
|
||||
@ -35,12 +36,12 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } =
|
||||
trpc.field.signFieldWithToken.useMutation();
|
||||
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const {
|
||||
mutateAsync: removeSignedFieldWithToken,
|
||||
isLoading: isRemoveSignedFieldWithTokenLoading,
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation();
|
||||
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
||||
|
||||
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
|
||||
|
||||
@ -61,6 +62,7 @@ export const TextField = ({ field, recipient }: TextFieldProps) => {
|
||||
|
||||
void executeActionAuthProcedure({
|
||||
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
|
||||
actionTarget: field.type,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -14,6 +14,10 @@ import {
|
||||
SETTINGS_PAGE_SHORTCUT,
|
||||
TEMPLATES_PAGE_SHORTCUT,
|
||||
} 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 { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import {
|
||||
@ -82,6 +86,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
@ -9,65 +13,51 @@ import { trpc } from '@documenso/trpc/react';
|
||||
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 { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
backupCode: z.string(),
|
||||
export const ZDisable2FAForm = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
||||
typeof ZDisableTwoFactorAuthenticationForm
|
||||
>;
|
||||
export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
|
||||
|
||||
export type DisableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const DisableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DisableAuthenticatorAppDialogProps) => {
|
||||
export const DisableAuthenticatorAppDialog = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: disableTwoFactorAuthentication } =
|
||||
trpc.twoFactorAuthentication.disable.useMutation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
||||
const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();
|
||||
|
||||
const disable2FAForm = useForm<TDisable2FAForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
backupCode: '',
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
||||
resolver: zodResolver(ZDisable2FAForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
||||
disableTwoFactorAuthenticationForm.formState;
|
||||
const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;
|
||||
|
||||
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
backupCode,
|
||||
}: TDisableTwoFactorAuthenticationForm) => {
|
||||
const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => {
|
||||
try {
|
||||
await disableTwoFactorAuthentication({ password, backupCode });
|
||||
await disable2FA({ token });
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication disabled',
|
||||
@ -76,7 +66,7 @@ export const DisableAuthenticatorAppDialog = ({
|
||||
});
|
||||
|
||||
flushSync(() => {
|
||||
onOpenChange(false);
|
||||
setIsOpen(false);
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
@ -91,74 +81,51 @@ export const DisableAuthenticatorAppDialog = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild={true}>
|
||||
<Button className="flex-shrink-0" variant="destructive">
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Authenticator App</DialogTitle>
|
||||
<DialogTitle>Disable 2FA</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
To disable the Authenticator App for your account, please enter your password and a
|
||||
backup code. If you do not have a backup code available, please contact support.
|
||||
Please provide a token from the authenticator, or a backup code. If you do not have a
|
||||
backup code available, please contact support.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...disableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onDisableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<fieldset
|
||||
className="flex flex-col gap-y-4"
|
||||
disabled={isDisableTwoFactorAuthenticationSubmitting}
|
||||
>
|
||||
<Form {...disable2FAForm}>
|
||||
<form onSubmit={disable2FAForm.handleSubmit(onDisable2FAFormSubmit)}>
|
||||
<fieldset className="flex flex-col gap-y-4" disabled={isDisable2FASubmitting}>
|
||||
<FormField
|
||||
name="password"
|
||||
control={disableTwoFactorAuthenticationForm.control}
|
||||
name="token"
|
||||
control={disable2FAForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
<Input {...field} placeholder="Token" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDisableTwoFactorAuthenticationSubmitting}
|
||||
>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</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 { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import { renderSVG } from 'uqr';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -11,11 +14,13 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
@ -26,85 +31,60 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
export const ZSetupTwoFactorAuthenticationForm = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
});
|
||||
|
||||
export type TSetupTwoFactorAuthenticationForm = z.infer<typeof ZSetupTwoFactorAuthenticationForm>;
|
||||
|
||||
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
||||
export const ZEnable2FAForm = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
||||
export type TEnable2FAForm = z.infer<typeof ZEnable2FAForm>;
|
||||
|
||||
export type EnableAuthenticatorAppDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnableAuthenticatorAppDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EnableAuthenticatorAppDialogProps) => {
|
||||
export const EnableAuthenticatorAppDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||
trpc.twoFactorAuthentication.setup.useMutation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
|
||||
const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const {
|
||||
mutateAsync: enableTwoFactorAuthentication,
|
||||
data: enableTwoFactorAuthenticationData,
|
||||
isLoading: isEnableTwoFactorAuthenticationDataLoading,
|
||||
} = trpc.twoFactorAuthentication.enable.useMutation();
|
||||
|
||||
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
mutateAsync: setup2FA,
|
||||
data: setup2FAData,
|
||||
isLoading: isSettingUp2FA,
|
||||
} = trpc.twoFactorAuthentication.setup.useMutation({
|
||||
onError: () => {
|
||||
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 code correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
||||
setupTwoFactorAuthenticationForm.formState;
|
||||
|
||||
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
||||
const enable2FAForm = useForm<TEnable2FAForm>({
|
||||
defaultValues: {
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
||||
resolver: zodResolver(ZEnable2FAForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
||||
enableTwoFactorAuthenticationForm.formState;
|
||||
const { isSubmitting: isEnabling2FA } = enable2FAForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) {
|
||||
return 'setup';
|
||||
}
|
||||
|
||||
if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) {
|
||||
return 'enable';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [
|
||||
setupTwoFactorAuthenticationData,
|
||||
isSetupTwoFactorAuthenticationSubmitting,
|
||||
enableTwoFactorAuthenticationData,
|
||||
isEnableTwoFactorAuthenticationSubmitting,
|
||||
]);
|
||||
|
||||
const onSetupTwoFactorAuthenticationFormSubmit = async ({
|
||||
password,
|
||||
}: TSetupTwoFactorAuthenticationForm) => {
|
||||
const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => {
|
||||
try {
|
||||
await setupTwoFactorAuthentication({ password });
|
||||
const data = await enable2FA({ code: token });
|
||||
|
||||
setRecoveryCodes(data.recoveryCodes);
|
||||
|
||||
toast({
|
||||
title: 'Two-factor authentication enabled',
|
||||
description:
|
||||
'You will now be required to enter a code from your authenticator app when signing in.',
|
||||
});
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to setup two-factor authentication',
|
||||
@ -116,8 +96,8 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
};
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) {
|
||||
const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], {
|
||||
if (recoveryCodes) {
|
||||
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
@ -128,175 +108,126 @@ export const EnableAuthenticatorAppDialog = ({
|
||||
}
|
||||
};
|
||||
|
||||
const onEnableTwoFactorAuthenticationFormSubmit = async ({
|
||||
token,
|
||||
}: TEnableTwoFactorAuthenticationForm) => {
|
||||
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',
|
||||
});
|
||||
const handleEnable2FA = async () => {
|
||||
if (!setup2FAData) {
|
||||
await setup2FA();
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the form when the Dialog closes
|
||||
if (!open) {
|
||||
setupTwoFactorAuthenticationForm.reset();
|
||||
enable2FAForm.reset();
|
||||
|
||||
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
|
||||
setRecoveryCodes(null);
|
||||
router.refresh();
|
||||
}
|
||||
}, [open, setupTwoFactorAuthenticationForm]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild={true}>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
loading={isSettingUp2FA}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleEnable2FA();
|
||||
}}
|
||||
>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
{step === 'setup' && (
|
||||
<DialogDescription>
|
||||
To enable two-factor authentication, please enter your password below.
|
||||
</DialogDescription>
|
||||
)}
|
||||
<DialogContent position="center">
|
||||
{setup2FAData && (
|
||||
<>
|
||||
{recoveryCodes ? (
|
||||
<div>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Backup codes</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'view' && (
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||
</div>
|
||||
|
||||
{match(step)
|
||||
.with('setup', () => {
|
||||
return (
|
||||
<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 className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Close</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={downloadRecoveryCodes}>Download</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...enable2FAForm}>
|
||||
<form onSubmit={enable2FAForm.handleSubmit(onEnable2FAFormSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||
<DialogDescription>
|
||||
To enable two-factor authentication, scan the following QR code using your
|
||||
authenticator app.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<fieldset disabled={isEnabling2FA} className="mt-4 flex flex-col gap-y-4">
|
||||
<div
|
||||
className="flex h-36 justify-center"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(setup2FAData?.uri ?? ''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If your authenticator app does not support QR codes, you can use the following
|
||||
code instead:
|
||||
</p>
|
||||
|
||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||
{setup2FAData?.secret}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Once you have scanned the QR code or entered the code manually, enter the code
|
||||
provided by your authenticator app below.
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
name="token"
|
||||
control={enable2FAForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={isEnabling2FA}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('enable', () => (
|
||||
<Form {...enableTwoFactorAuthenticationForm}>
|
||||
<form
|
||||
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
||||
onEnableTwoFactorAuthenticationFormSubmit,
|
||||
)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
To enable two-factor authentication, scan the following QR code using your
|
||||
authenticator app.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex h-36 justify-center"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
If your authenticator app does not support QR codes, you can use the following
|
||||
code instead:
|
||||
</p>
|
||||
|
||||
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||
{setupTwoFactorAuthenticationData?.secret}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Once you have scanned the QR code or entered the code manually, enter the code
|
||||
provided by your authenticator app below.
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
name="token"
|
||||
control={enableTwoFactorAuthenticationForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
))
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={downloadRecoveryCodes}
|
||||
disabled={!enableTwoFactorAuthenticationData?.recoveryCodes}
|
||||
loading={isEnableTwoFactorAuthenticationDataLoading}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -1,34 +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
|
||||
variant="outline"
|
||||
className="bg-background flex-shrink-0"
|
||||
onClick={() => setIsOpen(true)}
|
||||
disabled={!isTwoFactorEnabled}
|
||||
>
|
||||
View Codes
|
||||
</Button>
|
||||
|
||||
<ViewRecoveryCodesDialog
|
||||
key={isOpen ? 'open' : 'closed'}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@ -6,69 +8,61 @@ import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
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 { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
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 { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
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 ViewRecoveryCodesDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
export const ViewRecoveryCodesDialog = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: viewRecoveryCodes,
|
||||
data: viewRecoveryCodesData,
|
||||
isLoading: isViewRecoveryCodesDataLoading,
|
||||
data: recoveryCodes,
|
||||
mutate,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||
|
||||
// error?.data?.code
|
||||
|
||||
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
token: '',
|
||||
},
|
||||
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||
});
|
||||
|
||||
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
||||
|
||||
const step = useMemo(() => {
|
||||
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
||||
return 'authenticate';
|
||||
}
|
||||
|
||||
return 'view';
|
||||
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) {
|
||||
const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], {
|
||||
if (recoveryCodes) {
|
||||
const blob = new Blob([recoveryCodes.join('\n')], {
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
@ -79,105 +73,88 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
||||
}
|
||||
};
|
||||
|
||||
const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => {
|
||||
try {
|
||||
await viewRecoveryCodes({ password });
|
||||
} catch (_err) {
|
||||
toast({
|
||||
title: 'Unable to view recovery codes',
|
||||
description:
|
||||
'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the form when the Dialog closes
|
||||
if (!open) {
|
||||
viewRecoveryCodesForm.reset();
|
||||
}
|
||||
}, [open, viewRecoveryCodesForm]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||
{recoveryCodes ? (
|
||||
<div>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||
|
||||
{step === 'authenticate' && (
|
||||
<DialogDescription>
|
||||
To view your recovery codes, please enter your password below.
|
||||
</DialogDescription>
|
||||
)}
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'view' && (
|
||||
<DialogDescription>
|
||||
Your recovery codes are listed below. Please store them in a safe place.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<RecoveryCodeList recoveryCodes={recoveryCodes} />
|
||||
|
||||
{match(step)
|
||||
.with('authenticate', () => {
|
||||
return (
|
||||
<Form {...viewRecoveryCodesForm}>
|
||||
<form
|
||||
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
||||
className="flex flex-col gap-y-4"
|
||||
>
|
||||
<FormField
|
||||
name="password"
|
||||
control={viewRecoveryCodesForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Close</Button>
|
||||
</DialogClose>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Button onClick={downloadRecoveryCodes}>Download</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
) : (
|
||||
<Form {...viewRecoveryCodesForm}>
|
||||
<form onSubmit={viewRecoveryCodesForm.handleSubmit((value) => mutate(value))}>
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please provide a token from your authenticator, or a backup code.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<fieldset className="flex flex-col space-y-4" disabled={isLoading}>
|
||||
<FormField
|
||||
name="token"
|
||||
control={viewRecoveryCodesForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{match(AppError.parseError(error).message)
|
||||
.with(
|
||||
ErrorCode.INCORRECT_TWO_FACTOR_CODE,
|
||||
() => 'Invalid code. Please try again.',
|
||||
)
|
||||
.otherwise(
|
||||
() => 'Something went wrong. Please try again or contact support.',
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
})
|
||||
.with('view', () => (
|
||||
<div>
|
||||
{viewRecoveryCodesData?.recoveryCodes && (
|
||||
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-row-reverse items-center gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!viewRecoveryCodesData?.recoveryCodes}
|
||||
loading={isViewRecoveryCodesDataLoading}
|
||||
onClick={downloadRecoveryCodes}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
<Button type="submit" loading={isLoading}>
|
||||
View
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -154,7 +154,7 @@ export const ClaimPublicProfileDialogForm = ({
|
||||
|
||||
<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>'}
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
@ -124,7 +124,7 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
};
|
||||
|
||||
const onSignInWithPasskey = async () => {
|
||||
if (!browserSupportsWebAuthn()) {
|
||||
if (!browserSupportsWebAuthn) {
|
||||
toast({
|
||||
title: 'Not supported',
|
||||
description: 'Passkeys are not supported on this browser',
|
||||
|
||||
@ -24,7 +24,7 @@ export const UserProfileSkeleton = ({ className, user, rows = 2 }: UserProfileSk
|
||||
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}
|
||||
</div>
|
||||
|
||||
|
||||
@ -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_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_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_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. |
|
||||
|
||||
@ -34,6 +34,7 @@ services:
|
||||
- NEXT_PRIVATE_DIRECT_DATABASE_URL=${NEXT_PRIVATE_DIRECT_DATABASE_URL:-${NEXT_PRIVATE_DATABASE_URL}}
|
||||
- NEXT_PUBLIC_UPLOAD_TRANSPORT=${NEXT_PUBLIC_UPLOAD_TRANSPORT:-database}
|
||||
- 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_BUCKET=${NEXT_PRIVATE_UPLOAD_BUCKET}
|
||||
- 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} */
|
||||
module.exports = {
|
||||
'**/*.{ts,tsx,cts,mts}': (files) => files.map((file) => `eslint --fix ${file}`),
|
||||
'**/*.{js,jsx,cjs,mjs}': (files) => files.map((file) => `prettier --write ${file}`),
|
||||
'**/*.{yml,mdx}': (files) => files.map((file) => `prettier --write ${file}`),
|
||||
'**/*.{ts,tsx,cts,mts}': [eslint, prettier],
|
||||
'**/*.{js,jsx,cjs,mjs}': [prettier],
|
||||
'**/*.{yml,mdx}': [prettier],
|
||||
'**/*/package.json': 'npm run precommit',
|
||||
};
|
||||
|
||||
4097
package-lock.json
generated
4097
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -36,8 +36,8 @@
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-custom": "*",
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^14.0.0",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.2",
|
||||
"prettier": "^2.5.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"turbo": "^1.9.3"
|
||||
@ -48,6 +48,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@documenso/pdf-sign": "^0.1.0",
|
||||
"next-runtime-env": "^3.2.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
@ -124,7 +124,8 @@ test('[DOCUMENT_AUTH]: should allow signing with valid global auth', async ({ pa
|
||||
await unseedUser(recipientWithAccount.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_AUTH]: should deny signing document when required for global auth', async ({
|
||||
// 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();
|
||||
@ -151,7 +152,7 @@ test('[DOCUMENT_AUTH]: should deny signing document when required for global aut
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('paragraph')).toContainText(
|
||||
'Reauthentication is required to sign this document',
|
||||
'Reauthentication is required to sign the document',
|
||||
);
|
||||
|
||||
await unseedUser(user.id);
|
||||
@ -184,9 +185,13 @@ test('[DOCUMENT_AUTH]: should deny signing fields when required for global auth'
|
||||
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',
|
||||
'Reauthentication is required to sign the field',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
}
|
||||
@ -249,9 +254,13 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient au
|
||||
|
||||
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',
|
||||
'Reauthentication is required to sign the field',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
}
|
||||
@ -356,9 +365,13 @@ test('[DOCUMENT_AUTH]: should allow field signing when required for recipient an
|
||||
|
||||
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',
|
||||
'Reauthentication is required to sign the field',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
}
|
||||
|
||||
@ -5,12 +5,140 @@ import {
|
||||
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);
|
||||
@ -29,10 +157,8 @@ test('[DOCUMENT_FLOW]: add settings', async ({ page }) => {
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentAccessSelectValue')).toContainText('Require account');
|
||||
|
||||
// Set action auth.
|
||||
await page.getByTestId('documentActionSelectValue').click();
|
||||
await page.getByLabel('Require account').getByText('Require account').click();
|
||||
await expect(page.getByTestId('documentActionSelectValue')).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();
|
||||
|
||||
@ -1,12 +1,67 @@
|
||||
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();
|
||||
@ -29,8 +84,8 @@ test('[DOCUMENT_FLOW]: add signers', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('recipient2@documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Recipient 2');
|
||||
|
||||
// Display advanced settings.
|
||||
await page.getByLabel('Show advanced settings').click();
|
||||
// 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();
|
||||
|
||||
@ -1,17 +1,24 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from './fixtures/authentication';
|
||||
|
||||
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Sign in
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
const user = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
@ -73,3 +80,267 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
// Assert document was created
|
||||
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);
|
||||
});
|
||||
|
||||
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",
|
||||
"scripts": {
|
||||
"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\""
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
|
||||
|
||||
ENV_FILES.forEach((file) => {
|
||||
dotenv.config({
|
||||
path: path.join(__dirname, `../../${file}`),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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',
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:package-json/recommended',
|
||||
],
|
||||
|
||||
plugins: ['prettier', 'package-json', 'unused-imports'],
|
||||
plugins: ['package-json', 'unused-imports'],
|
||||
|
||||
env: {
|
||||
es2022: true,
|
||||
node: true,
|
||||
browser: true,
|
||||
es6: true,
|
||||
},
|
||||
|
||||
parser: '@typescript-eslint/parser',
|
||||
|
||||
@ -7,16 +7,14 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||
"@typescript-eslint/parser": "6.8.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-next": "13.4.19",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-turbo": "^1.9.3",
|
||||
"eslint-plugin-package-json": "^0.2.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
"@typescript-eslint/parser": "^7.1.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.1.3",
|
||||
"eslint-config-turbo": "^1.12.5",
|
||||
"eslint-plugin-package-json": "^0.10.4",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-unused-imports": "^3.1.0",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'YYYYMMDD',
|
||||
label: 'YYYY-MM-DD',
|
||||
value: 'YYYY-MM-DD',
|
||||
value: 'yyyy-MM-dd',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYY',
|
||||
|
||||
@ -20,14 +20,10 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
|
||||
value: 'Require account',
|
||||
isAuthRedirectRequired: true,
|
||||
},
|
||||
[DocumentAuth.PASSKEY]: {
|
||||
key: DocumentAuth.PASSKEY,
|
||||
value: 'Require passkey',
|
||||
},
|
||||
[DocumentAuth['2FA']]: {
|
||||
key: DocumentAuth['2FA'],
|
||||
value: 'Require 2FA',
|
||||
},
|
||||
// [DocumentAuthType.PASSKEY]: {
|
||||
// key: DocumentAuthType.PASSKEY,
|
||||
// value: 'Require passkey',
|
||||
// },
|
||||
[DocumentAuth.EXPLICIT_NONE]: {
|
||||
key: DocumentAuth.EXPLICIT_NONE,
|
||||
value: 'None (Overrides global settings)',
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
@ -22,7 +22,7 @@ import { sendConfirmationToken } from '../server-only/user/send-confirmation-tok
|
||||
import type { TAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||
import { ZAuthenticationResponseJSONSchema } from '../types/webauthn';
|
||||
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||
import { getAuthenticatorOptions } from '../utils/authenticator';
|
||||
import { getAuthenticatorRegistrationOptions } from '../utils/authenticator';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
@ -196,7 +196,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
|
||||
const user = passkey.User;
|
||||
|
||||
const { rpId, origin } = getAuthenticatorOptions();
|
||||
const { rpId, origin } = getAuthenticatorRegistrationOptions();
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: requestBodyCrediential,
|
||||
@ -212,36 +212,35 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
|
||||
const requestMetadata = extractNextAuthRequestMetadata(req);
|
||||
|
||||
// Explicit success state to reduce chances of bugs.
|
||||
if (verification?.verified === true) {
|
||||
await prisma.passkey.update({
|
||||
where: {
|
||||
id: passkey.id,
|
||||
},
|
||||
if (!verification?.verified) {
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: Number(user.id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
emailVerified: user.emailVerified?.toISOString() ?? null,
|
||||
} satisfies User;
|
||||
return null;
|
||||
}
|
||||
|
||||
await prisma.userSecurityAuditLog.create({
|
||||
await prisma.passkey.update({
|
||||
where: {
|
||||
id: passkey.id,
|
||||
},
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMetadata.ipAddress,
|
||||
userAgent: requestMetadata.userAgent,
|
||||
type: UserSecurityAuditLogType.SIGN_IN_PASSKEY_FAIL,
|
||||
lastUsedAt: new Date(),
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
},
|
||||
});
|
||||
|
||||
return null;
|
||||
return {
|
||||
id: Number(user.id),
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
emailVerified: user.emailVerified?.toISOString() ?? null,
|
||||
} satisfies User;
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@ -1,40 +1,30 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
token: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
token,
|
||||
user,
|
||||
password,
|
||||
requestMetadata,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
|
||||
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
@ -17,25 +17,38 @@ export const enableTwoFactorAuthentication = async ({
|
||||
code,
|
||||
requestMetadata,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
|
||||
throw new AppError('TWO_FACTOR_ALREADY_ENABLED');
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
throw new AppError('TWO_FACTOR_SETUP_REQUIRED');
|
||||
}
|
||||
|
||||
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
|
||||
|
||||
if (!isValidToken) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.$transaction(async (tx) => {
|
||||
let recoveryCodes: string[] = [];
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const updatedUser = await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
recoveryCodes = getBackupCodes({ user: updatedUser }) ?? [];
|
||||
|
||||
if (recoveryCodes.length === 0) {
|
||||
throw new AppError('MISSING_BACKUP_CODE');
|
||||
}
|
||||
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@ -44,18 +57,7 @@ export const enableTwoFactorAuthentication = async ({
|
||||
ipAddress: requestMetadata?.ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
return { recoveryCodes };
|
||||
};
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { compare } from '@node-rs/bcrypt';
|
||||
import { base32 } from '@scure/base';
|
||||
import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
@ -12,14 +11,12 @@ import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
type SetupTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const ISSUER = 'Documenso';
|
||||
|
||||
export const setupTwoFactorAuthentication = async ({
|
||||
user,
|
||||
password,
|
||||
}: SetupTwoFactorAuthenticationOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
@ -27,20 +24,6 @@ export const setupTwoFactorAuthentication = async ({
|
||||
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(10);
|
||||
|
||||
const backupCodes = Array.from({ length: 10 })
|
||||
|
||||
30
packages/lib/server-only/2fa/view-backup-codes.ts
Normal file
30
packages/lib/server-only/2fa/view-backup-codes.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type ViewBackupCodesOptions = {
|
||||
user: User;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => {
|
||||
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
|
||||
|
||||
if (!isValid) {
|
||||
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
|
||||
}
|
||||
|
||||
const backupCodes = getBackupCodes({ user });
|
||||
|
||||
if (!backupCodes) {
|
||||
throw new AppError('MISSING_BACKUP_CODE');
|
||||
}
|
||||
|
||||
return backupCodes;
|
||||
};
|
||||
@ -1,76 +0,0 @@
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||
import type { AuthenticatorTransportFuture } from '@simplewebauthn/types';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Passkey } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||
|
||||
type CreatePasskeyAuthenticationOptions = {
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The ID of the passkey to request authentication for.
|
||||
*
|
||||
* If not set, we allow the browser client to handle choosing.
|
||||
*/
|
||||
preferredPasskeyId?: string;
|
||||
};
|
||||
|
||||
export const createPasskeyAuthenticationOptions = async ({
|
||||
userId,
|
||||
preferredPasskeyId,
|
||||
}: CreatePasskeyAuthenticationOptions) => {
|
||||
const { rpId, timeout } = getAuthenticatorOptions();
|
||||
|
||||
let preferredPasskey: Pick<Passkey, 'credentialId' | 'transports'> | null = null;
|
||||
|
||||
if (preferredPasskeyId) {
|
||||
preferredPasskey = await prisma.passkey.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
id: preferredPasskeyId,
|
||||
},
|
||||
select: {
|
||||
credentialId: true,
|
||||
transports: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!preferredPasskey) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Requested passkey not found');
|
||||
}
|
||||
}
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
userVerification: 'preferred',
|
||||
timeout,
|
||||
allowCredentials: preferredPasskey
|
||||
? [
|
||||
{
|
||||
id: preferredPasskey.credentialId,
|
||||
type: 'public-key',
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
transports: preferredPasskey.transports as AuthenticatorTransportFuture[],
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const { secondaryId } = await prisma.verificationToken.create({
|
||||
data: {
|
||||
userId,
|
||||
token: options.challenge,
|
||||
expires: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
identifier: 'PASSKEY_CHALLENGE',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
tokenReference: secondaryId,
|
||||
options,
|
||||
};
|
||||
};
|
||||
@ -5,7 +5,7 @@ import { DateTime } from 'luxon';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { PASSKEY_TIMEOUT } from '../../constants/auth';
|
||||
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
|
||||
|
||||
type CreatePasskeyRegistrationOptions = {
|
||||
userId: number;
|
||||
@ -27,7 +27,7 @@ export const createPasskeyRegistrationOptions = async ({
|
||||
|
||||
const { passkeys } = user;
|
||||
|
||||
const { rpName, rpId: rpID } = getAuthenticatorOptions();
|
||||
const { rpName, rpId: rpID } = getAuthenticatorRegistrationOptions();
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
|
||||
@ -3,14 +3,14 @@ import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
|
||||
|
||||
type CreatePasskeySigninOptions = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySigninOptions) => {
|
||||
const { rpId, timeout } = getAuthenticatorOptions();
|
||||
const { rpId, timeout } = getAuthenticatorRegistrationOptions();
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rpId,
|
||||
|
||||
@ -7,7 +7,7 @@ import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||
import { MAXIMUM_PASSKEYS } from '../../constants/auth';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||
import { getAuthenticatorRegistrationOptions } from '../../utils/authenticator';
|
||||
|
||||
type CreatePasskeyOptions = {
|
||||
userId: number;
|
||||
@ -64,7 +64,7 @@ export const createPasskey = async ({
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Challenge token expired');
|
||||
}
|
||||
|
||||
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorOptions();
|
||||
const { rpId: expectedRPID, origin: expectedOrigin } = getAuthenticatorRegistrationOptions();
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: verificationResponse,
|
||||
|
||||
@ -11,7 +11,6 @@ export interface FindPasskeysOptions {
|
||||
orderBy?: {
|
||||
column: keyof Passkey;
|
||||
direction: 'asc' | 'desc';
|
||||
nulls?: Prisma.NullsOrder;
|
||||
};
|
||||
}
|
||||
|
||||
@ -22,9 +21,8 @@ export const findPasskeys = async ({
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
}: FindPasskeysOptions) => {
|
||||
const orderByColumn = orderBy?.column ?? 'lastUsedAt';
|
||||
const orderByColumn = orderBy?.column ?? 'name';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
const orderByNulls: Prisma.NullsOrder | undefined = orderBy?.nulls ?? 'last';
|
||||
|
||||
const whereClause: Prisma.PasskeyWhereInput = {
|
||||
userId,
|
||||
@ -43,10 +41,7 @@ export const findPasskeys = async ({
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
[orderByColumn]: {
|
||||
sort: orderByDirection,
|
||||
nulls: orderByNulls,
|
||||
},
|
||||
[orderByColumn]: orderByDirection,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@ -7,11 +7,8 @@ import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
import { sealDocument } from './seal-document';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
|
||||
@ -46,8 +43,6 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio
|
||||
export const completeDocumentWithToken = async ({
|
||||
token,
|
||||
documentId,
|
||||
userId,
|
||||
authOptions,
|
||||
requestMetadata,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
'use server';
|
||||
@ -79,22 +74,24 @@ export const completeDocumentWithToken = async ({
|
||||
throw new Error(`Recipient ${recipient.id} has unsigned fields`);
|
||||
}
|
||||
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
// Document reauth for completing documents is currently not required.
|
||||
|
||||
const isValid = await isRecipientAuthorized({
|
||||
type: 'ACTION',
|
||||
document: document,
|
||||
recipient: recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
});
|
||||
// const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
// documentAuth: document.authOptions,
|
||||
// recipientAuth: recipient.authOptions,
|
||||
// });
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||
}
|
||||
// const isValid = await isRecipientAuthorized({
|
||||
// type: 'ACTION',
|
||||
// document: document,
|
||||
// recipient: recipient,
|
||||
// userId,
|
||||
// authOptions,
|
||||
// });
|
||||
|
||||
// if (!isValid) {
|
||||
// throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||
// }
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.recipient.update({
|
||||
@ -121,7 +118,7 @@ export const completeDocumentWithToken = async ({
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
actionAuth: derivedRecipientActionAuth || undefined,
|
||||
// actionAuth: derivedRecipientActionAuth || undefined,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@ -30,6 +30,27 @@ export interface GetDocumentAndRecipientByTokenOptions {
|
||||
*/
|
||||
requireAccessAuth?: boolean;
|
||||
}
|
||||
export type GetDocumentByTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const getDocumentByToken = async ({ token }: GetDocumentByTokenOptions) => {
|
||||
if (!token) {
|
||||
throw new Error('Missing token');
|
||||
}
|
||||
|
||||
const result = await prisma.document.findFirstOrThrow({
|
||||
where: {
|
||||
Recipient: {
|
||||
some: {
|
||||
token,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export type DocumentAndSender = Awaited<ReturnType<typeof getDocumentAndSenderByToken>>;
|
||||
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { DocumentWithDetails } from '@documenso/prisma/types/document';
|
||||
|
||||
import { getDocumentWhereInput } from './get-document-by-id';
|
||||
|
||||
export type GetDocumentWithDetailsByIdOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const getDocumentWithDetailsById = async ({
|
||||
id,
|
||||
userId,
|
||||
teamId,
|
||||
}: GetDocumentWithDetailsByIdOptions): Promise<DocumentWithDetails> => {
|
||||
const documentWhereInput = await getDocumentWhereInput({
|
||||
documentId: id,
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return await prisma.document.findFirstOrThrow({
|
||||
where: documentWhereInput,
|
||||
include: {
|
||||
documentData: true,
|
||||
documentMeta: true,
|
||||
Recipient: true,
|
||||
Field: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,15 +1,10 @@
|
||||
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||
|
||||
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import type { TAuthenticationResponseJSONSchema } from '../../types/webauthn';
|
||||
import { getAuthenticatorOptions } from '../../utils/authenticator';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
|
||||
type IsRecipientAuthorizedOptions = {
|
||||
@ -31,7 +26,7 @@ type IsRecipientAuthorizedOptions = {
|
||||
authOptions?: TDocumentAuthMethods;
|
||||
};
|
||||
|
||||
const getRecipient = async (email: string) => {
|
||||
const getUserByEmail = async (email: string) => {
|
||||
return await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
@ -68,25 +63,18 @@ export const isRecipientAuthorized = async ({
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create auth options when none are passed for account.
|
||||
if (!authOptions && authMethod === DocumentAuth.ACCOUNT) {
|
||||
authOptions = {
|
||||
type: DocumentAuth.ACCOUNT,
|
||||
};
|
||||
}
|
||||
|
||||
// Authentication required does not match provided method.
|
||||
if (!authOptions || authOptions.type !== authMethod) {
|
||||
if (authOptions && authOptions.type !== authMethod) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await match(authOptions)
|
||||
.with({ type: DocumentAuth.ACCOUNT }, async () => {
|
||||
return await match(authMethod)
|
||||
.with(DocumentAuth.ACCOUNT, async () => {
|
||||
if (userId === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const recipientUser = await getRecipient(recipient.email);
|
||||
const recipientUser = await getUserByEmail(recipient.email);
|
||||
|
||||
if (!recipientUser) {
|
||||
return false;
|
||||
@ -94,138 +82,5 @@ export const isRecipientAuthorized = async ({
|
||||
|
||||
return recipientUser.id === userId;
|
||||
})
|
||||
.with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await isPasskeyAuthValid({
|
||||
userId,
|
||||
authenticationResponse,
|
||||
tokenReference,
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth['2FA'] }, async ({ token }) => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Should not be possible.
|
||||
if (!user) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
|
||||
}
|
||||
|
||||
return await verifyTwoFactorAuthenticationToken({
|
||||
user,
|
||||
totpCode: token,
|
||||
});
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
type VerifyPasskeyOptions = {
|
||||
/**
|
||||
* The ID of the user who initiated the request.
|
||||
*/
|
||||
userId: number;
|
||||
|
||||
/**
|
||||
* The secondary ID of the verification token.
|
||||
*/
|
||||
tokenReference: string;
|
||||
|
||||
/**
|
||||
* The response from the passkey authenticator.
|
||||
*/
|
||||
authenticationResponse: TAuthenticationResponseJSONSchema;
|
||||
|
||||
/**
|
||||
* Whether to throw errors when the user fails verification instead of returning
|
||||
* false.
|
||||
*/
|
||||
throwError?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the provided passkey authenticator response is valid and the user is
|
||||
* authenticated.
|
||||
*/
|
||||
const isPasskeyAuthValid = async (options: VerifyPasskeyOptions): Promise<boolean> => {
|
||||
return verifyPasskey(options)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies whether the provided passkey authenticator is valid and the user is
|
||||
* authenticated.
|
||||
*
|
||||
* Will throw an error if the user should not be authenticated.
|
||||
*/
|
||||
const verifyPasskey = async ({
|
||||
userId,
|
||||
tokenReference,
|
||||
authenticationResponse,
|
||||
}: VerifyPasskeyOptions): Promise<void> => {
|
||||
const passkey = await prisma.passkey.findFirst({
|
||||
where: {
|
||||
credentialId: Buffer.from(authenticationResponse.id, 'base64'),
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
const verificationToken = await prisma.verificationToken
|
||||
.delete({
|
||||
where: {
|
||||
userId,
|
||||
secondaryId: tokenReference,
|
||||
},
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!passkey) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Passkey not found');
|
||||
}
|
||||
|
||||
if (!verificationToken) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, 'Token not found');
|
||||
}
|
||||
|
||||
if (verificationToken.expires < new Date()) {
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, 'Token expired');
|
||||
}
|
||||
|
||||
const { rpId, origin } = getAuthenticatorOptions();
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: authenticationResponse,
|
||||
expectedChallenge: verificationToken.token,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpId,
|
||||
authenticator: {
|
||||
credentialID: new Uint8Array(Array.from(passkey.credentialId)),
|
||||
credentialPublicKey: new Uint8Array(passkey.credentialPublicKey),
|
||||
counter: Number(passkey.counter),
|
||||
},
|
||||
}).catch(() => null); // May want to log this for insights.
|
||||
|
||||
if (verification?.verified !== true) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'User is not authorized');
|
||||
}
|
||||
|
||||
await prisma.passkey.update({
|
||||
where: {
|
||||
id: passkey.id,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
counter: verification.authenticationInfo.newCounter,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import path from 'node:path';
|
||||
import { PDFDocument, PDFSignature, rectangle } from 'pdf-lib';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
@ -15,7 +15,9 @@ import { signPdf } from '@documenso/signing';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
import { putFile } from '../../universal/upload/put-file';
|
||||
import { flattenAnnotations } from '../pdf/flatten-annotations';
|
||||
import { insertFieldInPDF } from '../pdf/insert-field-in-pdf';
|
||||
import { normalizeSignatureAppearances } from '../pdf/normalize-signature-appearances';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
@ -91,31 +93,10 @@ export const sealDocument = async ({
|
||||
|
||||
const doc = await PDFDocument.load(pdfData);
|
||||
|
||||
const form = doc.getForm();
|
||||
|
||||
// Remove old signatures
|
||||
for (const field of form.getFields()) {
|
||||
if (field instanceof PDFSignature) {
|
||||
field.acroField.getWidgets().forEach((widget) => {
|
||||
widget.ensureAP();
|
||||
|
||||
try {
|
||||
widget.getNormalAppearance();
|
||||
} catch (e) {
|
||||
const { context } = widget.dict;
|
||||
|
||||
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||
|
||||
const streamRef = context.register(xobj);
|
||||
|
||||
widget.setNormalAppearance(streamRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Flatten the form to stop annotation layers from appearing above documenso fields
|
||||
form.flatten();
|
||||
// Normalize and flatten layers that could cause issues with the signature
|
||||
normalizeSignatureAppearances(doc);
|
||||
doc.getForm().flatten();
|
||||
flattenAnnotations(doc);
|
||||
|
||||
for (const field of fields) {
|
||||
await insertFieldInPDF(doc, field);
|
||||
|
||||
52
packages/lib/server-only/document/send-delete-email.ts
Normal file
52
packages/lib/server-only/document/send-delete-email.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentSuperDeleteEmailTemplate } from '@documenso/email/templates/document-super-delete';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
|
||||
export interface SendDeleteEmailOptions {
|
||||
documentId: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOptions) => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const { email, name } = document.User;
|
||||
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
|
||||
const template = createElement(DocumentSuperDeleteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
reason,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name: name || '',
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Document Deleted!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
};
|
||||
85
packages/lib/server-only/document/super-delete-document.ts
Normal file
85
packages/lib/server-only/document/super-delete-document.ts
Normal file
@ -0,0 +1,85 @@
|
||||
'use server';
|
||||
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
|
||||
export type SuperDeleteDocumentOptions = {
|
||||
id: number;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDocumentOptions) => {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
documentMeta: true,
|
||||
User: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const { status, User: user } = document;
|
||||
|
||||
// if the document is pending, send cancellation emails to all recipients
|
||||
if (status === DocumentStatus.PENDING && document.Recipient.length > 0) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||
const template = createElement(DocumentCancelTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: recipient.email,
|
||||
name: recipient.name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: 'Document Cancelled',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// always hard delete if deleted from admin
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
documentId: id,
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED,
|
||||
user,
|
||||
requestMetadata,
|
||||
data: {
|
||||
type: 'HARD',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return await tx.document.delete({ where: { id } });
|
||||
});
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
'use server';
|
||||
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
@ -74,6 +75,21 @@ export const updateDocumentSettings = async ({
|
||||
const newGlobalActionAuth =
|
||||
data?.globalActionAuth === undefined ? documentGlobalActionAuth : data.globalActionAuth;
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (newGlobalActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to set the action auth',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isTitleSame = data.title === document.title;
|
||||
const isGlobalAccessSame = documentGlobalAccessAuth === newGlobalAccessAuth;
|
||||
const isGlobalActionSame = documentGlobalActionAuth === newGlobalActionAuth;
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
diffFieldChanges,
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldType } from '@documenso/prisma/client';
|
||||
import type { Field, FieldType } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SetFieldsForDocumentOptions {
|
||||
@ -29,7 +29,7 @@ export const setFieldsForDocument = async ({
|
||||
documentId,
|
||||
fields,
|
||||
requestMetadata,
|
||||
}: SetFieldsForDocumentOptions) => {
|
||||
}: SetFieldsForDocumentOptions): Promise<Field[]> => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
@ -99,7 +99,7 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(async (tx) => {
|
||||
await Promise.all(
|
||||
return await Promise.all(
|
||||
linkedFields.map(async (field) => {
|
||||
const fieldSignerEmail = field.signerEmail.toLowerCase();
|
||||
|
||||
@ -218,5 +218,13 @@ export const setFieldsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return persistedFields;
|
||||
// Filter out fields that have been removed or have been updated.
|
||||
const filteredFields = existingFields.filter((field) => {
|
||||
const isRemoved = removedFields.find((removedField) => removedField.id === field.id);
|
||||
const isUpdated = persistedFields.find((persistedField) => persistedField.id === field.id);
|
||||
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
|
||||
return [...filteredFields, ...persistedFields];
|
||||
};
|
||||
|
||||
@ -79,18 +79,28 @@ export const signFieldWithToken = async ({
|
||||
throw new Error(`Field ${fieldId} has no recipientId`);
|
||||
}
|
||||
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
let { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const isValid = await isRecipientAuthorized({
|
||||
type: 'ACTION',
|
||||
document: document,
|
||||
recipient: recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
});
|
||||
// Override all non-signature fields to not require any auth.
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
derivedRecipientActionAuth = null;
|
||||
}
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// Only require auth on signature fields for now.
|
||||
if (field.type === FieldType.SIGNATURE) {
|
||||
isValid = await isRecipientAuthorized({
|
||||
type: 'ACTION',
|
||||
document: document,
|
||||
recipient: recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values');
|
||||
|
||||
63
packages/lib/server-only/pdf/flatten-annotations.ts
Normal file
63
packages/lib/server-only/pdf/flatten-annotations.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { PDFAnnotation, PDFRef } from 'pdf-lib';
|
||||
import {
|
||||
PDFDict,
|
||||
type PDFDocument,
|
||||
PDFName,
|
||||
drawObject,
|
||||
popGraphicsState,
|
||||
pushGraphicsState,
|
||||
rotateInPlace,
|
||||
translate,
|
||||
} from 'pdf-lib';
|
||||
|
||||
export const flattenAnnotations = (document: PDFDocument) => {
|
||||
const pages = document.getPages();
|
||||
|
||||
for (const page of pages) {
|
||||
const annotations = page.node.Annots()?.asArray() ?? [];
|
||||
|
||||
annotations.forEach((annotation) => {
|
||||
if (!(annotation instanceof PDFRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualAnnotation = page.node.context.lookup(annotation);
|
||||
|
||||
if (!(actualAnnotation instanceof PDFDict)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfAnnot = PDFAnnotation.fromDict(actualAnnotation);
|
||||
|
||||
const appearance = pdfAnnot.ensureAP();
|
||||
|
||||
// Skip annotations without a normal appearance
|
||||
if (!appearance.has(PDFName.of('N'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalAppearance = pdfAnnot.getNormalAppearance();
|
||||
const rectangle = pdfAnnot.getRectangle();
|
||||
|
||||
if (!(normalAppearance instanceof PDFRef)) {
|
||||
// Not sure how to get the reference to the normal appearance yet
|
||||
// so we should skip this annotation for now
|
||||
return;
|
||||
}
|
||||
|
||||
const xobj = page.node.newXObject('FlatAnnot', normalAppearance);
|
||||
|
||||
const operators = [
|
||||
pushGraphicsState(),
|
||||
translate(rectangle.x, rectangle.y),
|
||||
...rotateInPlace({ ...rectangle, rotation: 0 }),
|
||||
drawObject(xobj),
|
||||
popGraphicsState(),
|
||||
].filter((op) => !!op);
|
||||
|
||||
page.pushOperators(...operators);
|
||||
|
||||
page.node.removeAnnot(annotation);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
import type { PDFDocument } from 'pdf-lib';
|
||||
import { PDFSignature, rectangle } from 'pdf-lib';
|
||||
|
||||
export const normalizeSignatureAppearances = (document: PDFDocument) => {
|
||||
const form = document.getForm();
|
||||
|
||||
for (const field of form.getFields()) {
|
||||
if (field instanceof PDFSignature) {
|
||||
field.acroField.getWidgets().forEach((widget) => {
|
||||
widget.ensureAP();
|
||||
|
||||
try {
|
||||
widget.getNormalAppearance();
|
||||
} catch {
|
||||
const { context } = widget.dict;
|
||||
|
||||
const xobj = context.formXObject([rectangle(0, 0, 0, 0)]);
|
||||
|
||||
const streamRef = context.register(xobj);
|
||||
|
||||
widget.setNormalAppearance(streamRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import {
|
||||
type TRecipientActionAuthTypes,
|
||||
@ -11,9 +12,12 @@ import {
|
||||
} from '@documenso/lib/utils/document-audit-logs';
|
||||
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Recipient } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export interface SetRecipientsForDocumentOptions {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
@ -34,7 +38,7 @@ export const setRecipientsForDocument = async ({
|
||||
documentId,
|
||||
recipients,
|
||||
requestMetadata,
|
||||
}: SetRecipientsForDocumentOptions) => {
|
||||
}: SetRecipientsForDocumentOptions): Promise<Recipient[]> => {
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
@ -75,6 +79,23 @@ export const setRecipientsForDocument = async ({
|
||||
throw new Error('Document already complete');
|
||||
}
|
||||
|
||||
const recipientsHaveActionAuth = recipients.some((recipient) => recipient.actionAuth);
|
||||
|
||||
// Check if user has permission to set the global action auth.
|
||||
if (recipientsHaveActionAuth) {
|
||||
const isDocumentEnterprise = await isUserEnterprise({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!isDocumentEnterprise) {
|
||||
throw new AppError(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
'You do not have permission to set the action auth',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
@ -246,5 +267,17 @@ export const setRecipientsForDocument = async ({
|
||||
});
|
||||
}
|
||||
|
||||
return persistedRecipients;
|
||||
// Filter out recipients that have been removed or have been updated.
|
||||
const filteredRecipients: Recipient[] = existingRecipients.filter((recipient) => {
|
||||
const isRemoved = removedRecipients.find(
|
||||
(removedRecipient) => removedRecipient.id === recipient.id,
|
||||
);
|
||||
const isUpdated = persistedRecipients.find(
|
||||
(persistedRecipient) => persistedRecipient.id === recipient.id,
|
||||
);
|
||||
|
||||
return !isRemoved && !isUpdated;
|
||||
});
|
||||
|
||||
return [...filteredRecipients, ...persistedRecipients];
|
||||
};
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetCompletedDocumentsMonthlyResult = Array<{
|
||||
month: string;
|
||||
count: number;
|
||||
cume_count: number;
|
||||
}>;
|
||||
|
||||
type GetCompletedDocumentsMonthlyQueryResult = Array<{
|
||||
month: Date;
|
||||
count: bigint;
|
||||
cume_count: bigint;
|
||||
}>;
|
||||
|
||||
export const getCompletedDocumentsMonthly = async () => {
|
||||
const result = await prisma.$queryRaw<GetCompletedDocumentsMonthlyQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "updatedAt") AS "month",
|
||||
COUNT("id") as "count",
|
||||
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "updatedAt")) as "cume_count"
|
||||
FROM "Document"
|
||||
WHERE "status" = 'COMPLETED'
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
cume_count: Number(row.cume_count),
|
||||
}));
|
||||
};
|
||||
@ -1,11 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZAuthenticationResponseJSONSchema } from './webauthn';
|
||||
|
||||
/**
|
||||
* All the available types of document authentication options for both access and action.
|
||||
*/
|
||||
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', '2FA', 'EXPLICIT_NONE']);
|
||||
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'EXPLICIT_NONE']);
|
||||
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
||||
|
||||
const ZDocumentAuthAccountSchema = z.object({
|
||||
@ -16,25 +14,12 @@ const ZDocumentAuthExplicitNoneSchema = z.object({
|
||||
type: z.literal(DocumentAuth.EXPLICIT_NONE),
|
||||
});
|
||||
|
||||
const ZDocumentAuthPasskeySchema = z.object({
|
||||
type: z.literal(DocumentAuth.PASSKEY),
|
||||
authenticationResponse: ZAuthenticationResponseJSONSchema,
|
||||
tokenReference: z.string().min(1),
|
||||
});
|
||||
|
||||
const ZDocumentAuth2FASchema = z.object({
|
||||
type: z.literal(DocumentAuth['2FA']),
|
||||
token: z.string().min(4).max(10),
|
||||
});
|
||||
|
||||
/**
|
||||
* All the document auth methods for both accessing and actioning.
|
||||
*/
|
||||
export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
@ -50,16 +35,8 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
|
||||
*
|
||||
* Must keep these two in sync.
|
||||
*/
|
||||
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
]);
|
||||
export const ZDocumentActionAuthTypesSchema = z.enum([
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth['2FA'],
|
||||
]);
|
||||
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); // Todo: Add passkeys here.
|
||||
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
|
||||
|
||||
/**
|
||||
* The recipient access auth methods.
|
||||
@ -77,15 +54,11 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
|
||||
* Must keep these two in sync.
|
||||
*/
|
||||
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthAccountSchema, // Todo: Add passkeys here.
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
]);
|
||||
export const ZRecipientActionAuthTypesSchema = z.enum([
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth['2FA'],
|
||||
DocumentAuth.EXPLICIT_NONE,
|
||||
]);
|
||||
|
||||
|
||||
@ -132,6 +132,7 @@ const getS3Client = () => {
|
||||
|
||||
return new S3Client({
|
||||
endpoint: process.env.NEXT_PRIVATE_UPLOAD_ENDPOINT || undefined,
|
||||
forcePathStyle: process.env.NEXT_PRIVATE_UPLOAD_FORCE_PATH_STYLE === 'true',
|
||||
region: process.env.NEXT_PRIVATE_UPLOAD_REGION || 'us-east-1',
|
||||
credentials: hasCredentials
|
||||
? {
|
||||
|
||||
@ -4,7 +4,7 @@ import { PASSKEY_TIMEOUT } from '../constants/auth';
|
||||
/**
|
||||
* Extracts common fields to identify the RP (relying party)
|
||||
*/
|
||||
export const getAuthenticatorOptions = () => {
|
||||
export const getAuthenticatorRegistrationOptions = () => {
|
||||
const webAppBaseUrl = new URL(WEBAPP_BASE_URL);
|
||||
const rpId = webAppBaseUrl.hostname;
|
||||
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { env } from 'next-runtime-env';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||
import type { Subscription } from '.prisma/client';
|
||||
import { SubscriptionStatus } from '.prisma/client';
|
||||
|
||||
@ -13,3 +16,15 @@ export const subscriptionsContainsActivePlan = (
|
||||
subscription.status === SubscriptionStatus.ACTIVE && priceIds.includes(subscription.priceId),
|
||||
);
|
||||
};
|
||||
|
||||
export const subscriptionsContainActiveEnterprisePlan = (
|
||||
subscriptions?: Subscription[],
|
||||
): boolean => {
|
||||
const enterprisePlanId = env('NEXT_PUBLIC_STRIPE_ENTERPRISE_PLAN_MONTHLY_PRICE_ID');
|
||||
|
||||
if (!enterprisePlanId || !subscriptions || !IS_BILLING_ENABLED()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return subscriptionsContainsActivePlan(subscriptions, [enterprisePlanId]);
|
||||
};
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[secondaryId]` on the table `VerificationToken` will be added. If there are existing duplicate values, this will fail.
|
||||
- The required column `secondaryId` was added to the `VerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "VerificationToken" ADD COLUMN "secondaryId" TEXT NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_secondaryId_key" ON "VerificationToken"("secondaryId");
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user