Merge branch 'main' into feat/document-auth
This commit is contained in:
@@ -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
|
||||
|
||||
26
.env.example
26
.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)
|
||||
@@ -100,6 +113,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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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 { 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 = {
|
||||
@@ -25,57 +25,51 @@ 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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||
|
||||
type RecoveryCodesProps = {
|
||||
isTwoFactorEnabled: boolean;
|
||||
};
|
||||
|
||||
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className="flex-shrink-0"
|
||||
onClick={() => setIsOpen(true)}
|
||||
disabled={!isTwoFactorEnabled}
|
||||
>
|
||||
View Codes
|
||||
</Button>
|
||||
|
||||
<ViewRecoveryCodesDialog
|
||||
key={isOpen ? 'open' : 'closed'}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { 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>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
4091
package-lock.json
generated
4091
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": {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { TEST_USER } from '@documenso/prisma/seed/pr-718-add-stepper-component';
|
||||
|
||||
test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
@@ -73,3 +76,264 @@ test(`[PR-718]: should be able to create a document`, async ({ page }) => {
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to create a document with multiple recipients', async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Sign in
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Title').fill(documentTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signers
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Email*').fill('user1@example.com');
|
||||
await page.getByLabel('Name').fill('User 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByLabel('Email*').nth(1).fill('user2@example.com');
|
||||
await page.getByLabel('Name').nth(1).fill('User 2');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'User 1 Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByText('User 1 (user1@example.com)').click();
|
||||
await page.getByText('User 2 (user2@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'User 2 Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to create, send and sign a document', async ({ page }) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Sign in
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Title').fill(documentTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signers
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Email*').fill('user1@example.com');
|
||||
await page.getByLabel('Name').fill('User 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
await page.getByRole('link', { name: documentTitle }).click();
|
||||
|
||||
const url = await page.url().split('/');
|
||||
const documentId = url[url.length - 1];
|
||||
|
||||
const { token } = await getRecipientByEmail({
|
||||
email: 'user1@example.com',
|
||||
documentId: Number(documentId),
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
await page.waitForURL(`/sign/${token}`);
|
||||
|
||||
// Check if document has been viewed
|
||||
const { status } = await getDocumentByToken({ token });
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('You have signed')).toBeVisible();
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken({ token });
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/signin');
|
||||
|
||||
const documentTitle = `example-${Date.now()}.pdf`;
|
||||
|
||||
// Sign in
|
||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(TEST_USER.password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Upload document
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.locator('input[type=file]').evaluate((e) => {
|
||||
if (e instanceof HTMLInputElement) {
|
||||
e.click();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
await fileChooser.setFiles(path.join(__dirname, '../../../assets/example.pdf'));
|
||||
|
||||
// Wait to be redirected to the edit page
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'Add Title' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Title').fill(documentTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signers
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Email*').fill('user1@example.com');
|
||||
await page.getByLabel('Name').fill('User 1');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.getByLabel('Redirect URL').fill('https://documenso.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: documentTitle })).toBeVisible();
|
||||
await page.getByRole('link', { name: documentTitle }).click();
|
||||
|
||||
const url = await page.url().split('/');
|
||||
const documentId = url[url.length - 1];
|
||||
|
||||
const { token } = await getRecipientByEmail({
|
||||
email: 'user1@example.com',
|
||||
documentId: Number(documentId),
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
await page.waitForURL(`/sign/${token}`);
|
||||
|
||||
// Check if document has been viewed
|
||||
const { status } = await getDocumentByToken({ token });
|
||||
expect(status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Sign Document')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL('https://documenso.com');
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken({ token });
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
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": [],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,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),
|
||||
}));
|
||||
};
|
||||
@@ -182,6 +182,7 @@ const createCompletedDocument = async (sender: User, recipients: User[]) => {
|
||||
title: `[${PULL_REQUEST_NUMBER}] Document 1 - Completed`,
|
||||
status: DocumentStatus.COMPLETED,
|
||||
documentDataId: documentData.id,
|
||||
completedAt: new Date(),
|
||||
userId: sender.id,
|
||||
},
|
||||
});
|
||||
|
||||
3
packages/signing/constants/byte-range.ts
Normal file
3
packages/signing/constants/byte-range.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// We use stars as a placeholder since it's easy to find and replace,
|
||||
// the length of the placeholder is to support larger pdf files
|
||||
export const BYTE_RANGE_PLACEHOLDER = '**********';
|
||||
@@ -1,4 +1,3 @@
|
||||
import signer from 'node-signpdf';
|
||||
import {
|
||||
PDFArray,
|
||||
PDFDocument,
|
||||
@@ -9,6 +8,8 @@ import {
|
||||
rectangle,
|
||||
} from 'pdf-lib';
|
||||
|
||||
import { BYTE_RANGE_PLACEHOLDER } from '../constants/byte-range';
|
||||
|
||||
export type AddSigningPlaceholderOptions = {
|
||||
pdf: Buffer;
|
||||
};
|
||||
@@ -20,9 +21,9 @@ export const addSigningPlaceholder = async ({ pdf }: AddSigningPlaceholderOption
|
||||
const byteRange = PDFArray.withContext(doc.context);
|
||||
|
||||
byteRange.push(PDFNumber.of(0));
|
||||
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
|
||||
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
|
||||
byteRange.push(PDFName.of(signer.byteRangePlaceholder));
|
||||
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
|
||||
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
|
||||
byteRange.push(PDFName.of(BYTE_RANGE_PLACEHOLDER));
|
||||
|
||||
const signature = doc.context.obj({
|
||||
Type: 'Sig',
|
||||
72
packages/signing/helpers/update-signing-placeholder.test.ts
Normal file
72
packages/signing/helpers/update-signing-placeholder.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { updateSigningPlaceholder } from './update-signing-placeholder';
|
||||
|
||||
describe('updateSigningPlaceholder', () => {
|
||||
const pdf = Buffer.from(`
|
||||
20 0 obj
|
||||
<<
|
||||
/Type /Sig
|
||||
/Filter /Adobe.PPKLite
|
||||
/SubFilter /adbe.pkcs7.detached
|
||||
/ByteRange [ 0 /********** /********** /********** ]
|
||||
/Contents <0000000000000000000000000000000000000000000000000000000>
|
||||
/Reason (Signed by Documenso)
|
||||
/M (D:20210101000000Z)
|
||||
>>
|
||||
endobj
|
||||
`);
|
||||
|
||||
it('should not throw an error', () => {
|
||||
expect(() => updateSigningPlaceholder({ pdf })).not.toThrowError();
|
||||
});
|
||||
|
||||
it('should not modify the original PDF', () => {
|
||||
const result = updateSigningPlaceholder({ pdf });
|
||||
|
||||
expect(result.pdf).not.toEqual(pdf);
|
||||
});
|
||||
|
||||
it('should return a PDF with the same length as the original', () => {
|
||||
const result = updateSigningPlaceholder({ pdf });
|
||||
|
||||
expect(result.pdf).toHaveLength(pdf.length);
|
||||
});
|
||||
|
||||
it('should update the byte range and return it', () => {
|
||||
const result = updateSigningPlaceholder({ pdf });
|
||||
|
||||
expect(result.byteRange).toEqual([0, 184, 241, 92]);
|
||||
});
|
||||
|
||||
it('should only update the last signature in the PDF', () => {
|
||||
const pdf = Buffer.from(`
|
||||
20 0 obj
|
||||
<<
|
||||
/Type /Sig
|
||||
/Filter /Adobe.PPKLite
|
||||
/SubFilter /adbe.pkcs7.detached
|
||||
/ByteRange [ 0 /********** /********** /********** ]
|
||||
/Contents <0000000000000000000000000000000000000000000000000000000>
|
||||
/Reason (Signed by Documenso)
|
||||
/M (D:20210101000000Z)
|
||||
>>
|
||||
endobj
|
||||
21 0 obj
|
||||
<<
|
||||
/Type /Sig
|
||||
/Filter /Adobe.PPKLite
|
||||
/SubFilter /adbe.pkcs7.detached
|
||||
/ByteRange [ 0 /********** /********** /********** ]
|
||||
/Contents <0000000000000000000000000000000000000000000000000000000>
|
||||
/Reason (Signed by Documenso)
|
||||
/M (D:20210101000000Z)
|
||||
>>
|
||||
endobj
|
||||
`);
|
||||
|
||||
const result = updateSigningPlaceholder({ pdf });
|
||||
|
||||
expect(result.byteRange).toEqual([0, 512, 569, 92]);
|
||||
});
|
||||
});
|
||||
39
packages/signing/helpers/update-signing-placeholder.ts
Normal file
39
packages/signing/helpers/update-signing-placeholder.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type UpdateSigningPlaceholderOptions = {
|
||||
pdf: Buffer;
|
||||
};
|
||||
|
||||
export const updateSigningPlaceholder = ({ pdf }: UpdateSigningPlaceholderOptions) => {
|
||||
const length = pdf.length;
|
||||
|
||||
const byteRangePos = pdf.lastIndexOf('/ByteRange');
|
||||
const byteRangeStart = pdf.indexOf('[', byteRangePos);
|
||||
const byteRangeEnd = pdf.indexOf(']', byteRangePos);
|
||||
|
||||
const byteRangeSlice = pdf.subarray(byteRangeStart, byteRangeEnd + 1);
|
||||
|
||||
const signaturePos = pdf.indexOf('/Contents', byteRangeEnd);
|
||||
const signatureStart = pdf.indexOf('<', signaturePos);
|
||||
const signatureEnd = pdf.indexOf('>', signaturePos);
|
||||
|
||||
const signatureSlice = pdf.subarray(signatureStart, signatureEnd + 1);
|
||||
|
||||
const byteRange = [0, 0, 0, 0];
|
||||
|
||||
byteRange[1] = signatureStart;
|
||||
byteRange[2] = byteRange[1] + signatureSlice.length;
|
||||
byteRange[3] = length - byteRange[2];
|
||||
|
||||
const newByteRange = `[${byteRange.join(' ')}]`.padEnd(byteRangeSlice.length, ' ');
|
||||
|
||||
const updatedPdf = Buffer.concat([
|
||||
pdf.subarray(0, byteRangeStart),
|
||||
Buffer.from(newByteRange),
|
||||
pdf.subarray(byteRangeEnd + 1),
|
||||
]);
|
||||
|
||||
if (updatedPdf.length !== length) {
|
||||
throw new Error('Updated PDF length does not match original length');
|
||||
}
|
||||
|
||||
return { pdf: updatedPdf, byteRange };
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { signWithGoogleCloudHSM } from './transports/google-cloud-hsm';
|
||||
import { signWithLocalCert } from './transports/local-cert';
|
||||
|
||||
export type SignOptions = {
|
||||
@@ -11,6 +12,7 @@ export const signPdf = async ({ pdf }: SignOptions) => {
|
||||
|
||||
return await match(transport)
|
||||
.with('local', async () => signWithLocalCert({ pdf }))
|
||||
.with('gcloud-hsm', async () => signWithGoogleCloudHSM({ pdf }))
|
||||
.otherwise(() => {
|
||||
throw new Error(`Unsupported signing transport: ${transport}`);
|
||||
});
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
"index.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-signpdf": "^2.0.0",
|
||||
"@documenso/pdf-sign": "^0.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"ts-pattern": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.4"
|
||||
"vitest": "^1.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
79
packages/signing/transports/google-cloud-hsm.ts
Normal file
79
packages/signing/transports/google-cloud-hsm.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { signWithGCloud } from '@documenso/pdf-sign';
|
||||
|
||||
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
|
||||
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
|
||||
|
||||
export type SignWithGoogleCloudHSMOptions = {
|
||||
pdf: Buffer;
|
||||
};
|
||||
|
||||
export const signWithGoogleCloudHSM = async ({ pdf }: SignWithGoogleCloudHSMOptions) => {
|
||||
const keyPath = process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH;
|
||||
|
||||
if (!keyPath) {
|
||||
throw new Error('No certificate path provided for Google Cloud HSM signing');
|
||||
}
|
||||
|
||||
// To handle hosting in serverless environments like Vercel we can supply the base64 encoded
|
||||
// application credentials as an environment variable and write it to a file if it doesn't exist
|
||||
if (
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS &&
|
||||
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS
|
||||
) {
|
||||
if (!fs.existsSync(process.env.GOOGLE_APPLICATION_CREDENTIALS)) {
|
||||
fs.writeFileSync(
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS,
|
||||
Buffer.from(
|
||||
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS,
|
||||
'base64',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
|
||||
pdf: await addSigningPlaceholder({ pdf }),
|
||||
});
|
||||
|
||||
const pdfWithoutSignature = Buffer.concat([
|
||||
pdfWithPlaceholder.subarray(0, byteRange[1]),
|
||||
pdfWithPlaceholder.subarray(byteRange[2]),
|
||||
]);
|
||||
|
||||
const signatureLength = byteRange[2] - byteRange[1];
|
||||
|
||||
let cert: Buffer | null = null;
|
||||
|
||||
if (process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS) {
|
||||
cert = Buffer.from(
|
||||
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS,
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
if (!cert) {
|
||||
cert = Buffer.from(
|
||||
fs.readFileSync(
|
||||
process.env.NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH || './example/cert.crt',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const signature = signWithGCloud({
|
||||
keyPath,
|
||||
cert,
|
||||
content: pdfWithoutSignature,
|
||||
});
|
||||
|
||||
const signatureAsHex = signature.toString('hex');
|
||||
|
||||
const signedPdf = Buffer.concat([
|
||||
pdfWithPlaceholder.subarray(0, byteRange[1]),
|
||||
Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`),
|
||||
pdfWithPlaceholder.subarray(byteRange[2]),
|
||||
]);
|
||||
|
||||
return signedPdf;
|
||||
};
|
||||
@@ -1,32 +1,51 @@
|
||||
import signer from 'node-signpdf';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { addSigningPlaceholder } from '../helpers/addSigningPlaceholder';
|
||||
import { signWithP12 } from '@documenso/pdf-sign';
|
||||
|
||||
import { addSigningPlaceholder } from '../helpers/add-signing-placeholder';
|
||||
import { updateSigningPlaceholder } from '../helpers/update-signing-placeholder';
|
||||
|
||||
export type SignWithLocalCertOptions = {
|
||||
pdf: Buffer;
|
||||
};
|
||||
|
||||
export const signWithLocalCert = async ({ pdf }: SignWithLocalCertOptions) => {
|
||||
const pdfWithPlaceholder = await addSigningPlaceholder({ pdf });
|
||||
const { pdf: pdfWithPlaceholder, byteRange } = updateSigningPlaceholder({
|
||||
pdf: await addSigningPlaceholder({ pdf }),
|
||||
});
|
||||
|
||||
let p12Cert: Buffer | null = null;
|
||||
const pdfWithoutSignature = Buffer.concat([
|
||||
pdfWithPlaceholder.subarray(0, byteRange[1]),
|
||||
pdfWithPlaceholder.subarray(byteRange[2]),
|
||||
]);
|
||||
|
||||
const signatureLength = byteRange[2] - byteRange[1];
|
||||
|
||||
let cert: Buffer | null = null;
|
||||
|
||||
if (process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS) {
|
||||
p12Cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64');
|
||||
cert = Buffer.from(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS, 'base64');
|
||||
}
|
||||
|
||||
if (!p12Cert) {
|
||||
p12Cert = Buffer.from(
|
||||
if (!cert) {
|
||||
cert = Buffer.from(
|
||||
fs.readFileSync(process.env.NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH || './example/cert.p12'),
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE) {
|
||||
return signer.sign(pdfWithPlaceholder, p12Cert, {
|
||||
passphrase: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE,
|
||||
});
|
||||
}
|
||||
const signature = signWithP12({
|
||||
cert,
|
||||
content: pdfWithoutSignature,
|
||||
password: process.env.NEXT_PRIVATE_SIGNING_PASSPHRASE || undefined,
|
||||
});
|
||||
|
||||
return signer.sign(pdfWithPlaceholder, p12Cert);
|
||||
const signatureAsHex = signature.toString('hex');
|
||||
|
||||
const signedPdf = Buffer.concat([
|
||||
pdfWithPlaceholder.subarray(0, byteRange[1]),
|
||||
Buffer.from(`<${signatureAsHex.padEnd(signatureLength - 2, '0')}>`),
|
||||
pdfWithPlaceholder.subarray(byteRange[2]),
|
||||
]);
|
||||
|
||||
return signedPdf;
|
||||
};
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
|
||||
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
|
||||
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
|
||||
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
|
||||
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes';
|
||||
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZDisableTwoFactorAuthenticationMutationSchema,
|
||||
ZEnableTwoFactorAuthenticationMutationSchema,
|
||||
ZSetupTwoFactorAuthenticationMutationSchema,
|
||||
ZViewRecoveryCodesMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const twoFactorAuthenticationRouter = router({
|
||||
setup: authenticatedProcedure
|
||||
.input(ZSetupTwoFactorAuthenticationMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password } = input;
|
||||
|
||||
setup: authenticatedProcedure.mutation(async ({ ctx }) => {
|
||||
try {
|
||||
return await setupTwoFactorAuthentication({
|
||||
user,
|
||||
password,
|
||||
user: ctx.user,
|
||||
});
|
||||
}),
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to setup two-factor authentication. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
enable: authenticatedProcedure
|
||||
.input(ZEnableTwoFactorAuthenticationMutationSchema)
|
||||
@@ -44,7 +44,11 @@ export const twoFactorAuthenticationRouter = router({
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -59,16 +63,17 @@ export const twoFactorAuthenticationRouter = router({
|
||||
try {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password, backupCode } = input;
|
||||
|
||||
return await disableTwoFactorAuthentication({
|
||||
user,
|
||||
password,
|
||||
backupCode,
|
||||
token: input.token,
|
||||
requestMetadata: extractNextApiRequestMetadata(ctx.req),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@@ -81,38 +86,18 @@ export const twoFactorAuthenticationRouter = router({
|
||||
.input(ZViewRecoveryCodesMutationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password } = input;
|
||||
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: ErrorCode.TWO_FACTOR_SETUP_REQUIRED,
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.password || !compareSync(password, user.password)) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: ErrorCode.INCORRECT_PASSWORD,
|
||||
});
|
||||
}
|
||||
|
||||
const recoveryCodes = await getBackupCodes({ user });
|
||||
|
||||
return { recoveryCodes };
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof TRPCError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to view your recovery codes. Please try again later.',
|
||||
return await viewBackupCodes({
|
||||
user: ctx.user,
|
||||
token: input.token,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
throw AppError.parseErrorToTRPCError(err);
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZSetupTwoFactorAuthenticationMutationSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TSetupTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
typeof ZSetupTwoFactorAuthenticationMutationSchema
|
||||
>;
|
||||
|
||||
export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
});
|
||||
@@ -17,8 +9,7 @@ export type TEnableTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
>;
|
||||
|
||||
export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
backupCode: z.string().trim(),
|
||||
token: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
@@ -26,7 +17,7 @@ export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
|
||||
>;
|
||||
|
||||
export const ZViewRecoveryCodesMutationSchema = z.object({
|
||||
password: z.string().min(6).max(72),
|
||||
token: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>;
|
||||
|
||||
4
packages/tsconfig/process-env.d.ts
vendored
4
packages/tsconfig/process-env.d.ts
vendored
@@ -30,6 +30,10 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH?: string;
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS?: string;
|
||||
NEXT_PRIVATE_SIGNING_LOCAL_FILE_ENCODING?: string;
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH?: string;
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH?: string;
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS?: string;
|
||||
NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'resend' | 'smtp-auth' | 'smtp-api';
|
||||
|
||||
|
||||
@@ -178,9 +178,10 @@ export const AddSignersFormPartial = ({
|
||||
<Form {...form}>
|
||||
<div className="flex w-full flex-col gap-y-2">
|
||||
{signers.map((signer, index) => (
|
||||
<motion.div
|
||||
<motion.fieldset
|
||||
key={signer.id}
|
||||
data-native-id={signer.nativeId}
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
className={cn('grid grid-cols-8 gap-4 pb-4', {
|
||||
'border-b pt-2': showAdvancedSettings,
|
||||
})}
|
||||
@@ -312,7 +313,11 @@ export const AddSignersFormPartial = ({
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1 mt-auto">
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
>
|
||||
<SelectTrigger className="bg-background w-[60px]">
|
||||
{/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */}
|
||||
{ROLE_ICONS[field.value as RecipientRole]}
|
||||
@@ -367,7 +372,7 @@ export const AddSignersFormPartial = ({
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.fieldset>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,6 +48,14 @@
|
||||
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
|
||||
"NEXT_PRIVATE_DATABASE_URL",
|
||||
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
||||
"NEXT_PRIVATE_SIGNING_TRANSPORT",
|
||||
"NEXT_PRIVATE_SIGNING_PASSPHRASE",
|
||||
"NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH",
|
||||
"NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS",
|
||||
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH",
|
||||
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH",
|
||||
"NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS",
|
||||
"NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS",
|
||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||
"NEXT_PRIVATE_GOOGLE_CLIENT_SECRET",
|
||||
"NEXT_PUBLIC_UPLOAD_TRANSPORT",
|
||||
@@ -93,6 +101,7 @@
|
||||
"DATABASE_URL_UNPOOLED",
|
||||
"POSTGRES_PRISMA_URL",
|
||||
"POSTGRES_URL_NON_POOLING",
|
||||
"GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"E2E_TEST_AUTHENTICATE_USERNAME",
|
||||
"E2E_TEST_AUTHENTICATE_USER_EMAIL",
|
||||
"E2E_TEST_AUTHENTICATE_USER_PASSWORD"
|
||||
|
||||
Reference in New Issue
Block a user