mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 18:21:32 +10:00
chore: fixed conflicts
Signed-off-by: Adithya Krishna <adi@documenso.com>
This commit is contained in:
@ -2,6 +2,11 @@
|
|||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="secret"
|
NEXTAUTH_SECRET="secret"
|
||||||
|
|
||||||
|
# [[CRYPTO]]
|
||||||
|
# Application Key for symmetric encryption and decryption
|
||||||
|
# This should be a random string of at least 32 characters
|
||||||
|
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||||
|
|
||||||
# [[AUTH OPTIONAL]]
|
# [[AUTH OPTIONAL]]
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
|||||||
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@ -45,3 +45,4 @@ body:
|
|||||||
- label: I have included relevant environment information.
|
- label: I have included relevant environment information.
|
||||||
- label: I have included any relevant screenshots.
|
- label: I have included any relevant screenshots.
|
||||||
- label: I understand that this is a voluntary contribution and that there is no guarantee of resolution.
|
- label: I understand that this is a voluntary contribution and that there is no guarantee of resolution.
|
||||||
|
- label: I want to work on creating a PR for this issue if approved
|
||||||
16
.prettierignore
Normal file
16
.prettierignore
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
public
|
||||||
|
**/**/node_modules
|
||||||
|
**/**/.next
|
||||||
|
**/**/public
|
||||||
|
|
||||||
|
*.lock
|
||||||
|
*.log
|
||||||
|
*.test.ts
|
||||||
|
|
||||||
|
.gitignore
|
||||||
|
.npmignore
|
||||||
|
.prettierignore
|
||||||
|
.DS_Store
|
||||||
|
.eslintignore
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
🚨 We are live on Product Hunt with Single Player Mode and the new free tier: [https://www.producthunt.com/products/documenso](https://www.producthunt.com/posts/documenso-singleplayer-mode)
|
||||||
|
|
||||||
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
<img src="https://github.com/documenso/documenso/assets/13398220/a643571f-0239-46a6-a73e-6bef38d1228b" alt="Documenso Logo">
|
||||||
|
|
||||||
<p align="center" style="margin-top: 20px">
|
<p align="center" style="margin-top: 20px">
|
||||||
@ -217,7 +219,7 @@ Then, inside the `documenso` folder, copy the example env file:
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
|
|
||||||
The following environement variables must be set:
|
The following environment variables must be set:
|
||||||
|
|
||||||
* `NEXTAUTH_URL`
|
* `NEXTAUTH_URL`
|
||||||
* `NEXTAUTH_SECRET`
|
* `NEXTAUTH_SECRET`
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3001",
|
"start": "next start -p 3001",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules",
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
@ -20,6 +21,7 @@
|
|||||||
"contentlayer": "^0.3.4",
|
"contentlayer": "^0.3.4",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
"next": "14.0.0",
|
"next": "14.0.0",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.3",
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
|
|||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
(
|
||||||
<div tw="relative h-full w-full flex flex-col items-center justify-center text-center">
|
<div tw="relative h-full w-full flex flex-col items-center justify-center text-center bg-white">
|
||||||
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||||
<img src={backgroundImage} alt="og-background" tw="absolute inset-0 w-full h-full" />
|
<img src={backgroundImage} alt="og-background" tw="absolute inset-0 w-full h-full" />
|
||||||
|
|
||||||
|
|||||||
@ -40,9 +40,9 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
|||||||
<span>{extraInfo}</span>
|
<span>{extraInfo}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border pr-2 shadow-sm hover:shadow">
|
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
||||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||||
<BarChart data={formattedData} margin={{ top: 30, right: 20 }}>
|
<BarChart data={formattedData}>
|
||||||
<XAxis dataKey="month" />
|
<XAxis dataKey="month" />
|
||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@ -55,7 +55,13 @@ export const BarMetric = <T extends Record<string, Record<keyof T[string], unkno
|
|||||||
formatter={(value) => [Number(value), label]}
|
formatter={(value) => [Number(value), label]}
|
||||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey={metricKey as string} fill="hsl(var(--primary))" label={label} />{' '}
|
<Bar
|
||||||
|
dataKey={metricKey as string}
|
||||||
|
maxBarSize={60}
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
label={label}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
|
|||||||
<div className={cn('flex flex-col', className)} {...props}>
|
<div className={cn('flex flex-col', className)} {...props}>
|
||||||
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
|
<h3 className="px-4 text-lg font-semibold">Total Funding Raised</h3>
|
||||||
|
|
||||||
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-4 shadow-sm hover:shadow">
|
<div className="border-border mt-2.5 flex flex-1 flex-col items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
|
<BarChart data={formattedData} margin={{ top: 40, right: 40, bottom: 20, left: 40 }}>
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" />
|
||||||
@ -51,7 +51,13 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)
|
|||||||
]}
|
]}
|
||||||
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="amount" fill="hsl(var(--primary))" label="Amount Raised" />
|
<Bar
|
||||||
|
dataKey="amount"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
label="Amount Raised"
|
||||||
|
maxBarSize={60}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
export type MonthlyNewUsersChartProps = {
|
||||||
|
className?: string;
|
||||||
|
data: GetUserMonthlyGrowthResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartProps) => {
|
||||||
|
const formattedData = [...data].reverse().map(({ month, count }) => {
|
||||||
|
return {
|
||||||
|
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'),
|
||||||
|
count: Number(count),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', className)}>
|
||||||
|
<div className="flex items-center px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Monthly 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" />
|
||||||
|
<YAxis />
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [Number(value).toLocaleString('en-US'), 'New Users']}
|
||||||
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={60}
|
||||||
|
label="New Users"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
|
||||||
|
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
|
export type MonthlyTotalUsersChartProps = {
|
||||||
|
className?: string;
|
||||||
|
data: GetUserMonthlyGrowthResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersChartProps) => {
|
||||||
|
const formattedData = [...data].reverse().map(({ month, cume_count: count }) => {
|
||||||
|
return {
|
||||||
|
month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('LLL'),
|
||||||
|
count: Number(count),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', className)}>
|
||||||
|
<div className="flex items-center px-4">
|
||||||
|
<h3 className="text-lg font-semibold">Monthly 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" />
|
||||||
|
<YAxis />
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [Number(value).toLocaleString('en-US'), 'Total Users']}
|
||||||
|
cursor={{ fill: 'hsl(var(--primary) / 10%)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="hsl(var(--primary))"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
maxBarSize={60}
|
||||||
|
label="Total Users"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
|
|
||||||
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
|
import { FUNDING_RAISED } from '~/app/(marketing)/open/data';
|
||||||
import { MetricCard } from '~/app/(marketing)/open/metric-card';
|
import { MetricCard } from '~/app/(marketing)/open/metric-card';
|
||||||
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
|
import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
|
||||||
@ -7,11 +9,23 @@ import { SalaryBands } from '~/app/(marketing)/open/salary-bands';
|
|||||||
import { BarMetric } from './bar-metrics';
|
import { BarMetric } from './bar-metrics';
|
||||||
import { CapTable } from './cap-table';
|
import { CapTable } from './cap-table';
|
||||||
import { FundingRaised } from './funding-raised';
|
import { FundingRaised } from './funding-raised';
|
||||||
|
import { MonthlyNewUsersChart } from './monthly-new-users-chart';
|
||||||
|
import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
|
||||||
import { TeamMembers } from './team-members';
|
import { TeamMembers } from './team-members';
|
||||||
import { OpenPageTooltip } from './tooltip';
|
import { OpenPageTooltip } from './tooltip';
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const GITHUB_HEADERS: Record<string, string> = {
|
||||||
|
accept: 'application/vnd.github.v3+json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
|
||||||
|
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
|
||||||
|
}
|
||||||
|
|
||||||
const ZGithubStatsResponse = z.object({
|
const ZGithubStatsResponse = z.object({
|
||||||
stargazers_count: z.number(),
|
stargazers_count: z.number(),
|
||||||
forks_count: z.number(),
|
forks_count: z.number(),
|
||||||
@ -22,6 +36,10 @@ const ZMergedPullRequestsResponse = z.object({
|
|||||||
total_count: z.number(),
|
total_count: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ZOpenIssuesResponse = z.object({
|
||||||
|
total_count: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
const ZStargazersLiveResponse = z.record(
|
const ZStargazersLiveResponse = z.record(
|
||||||
z.object({
|
z.object({
|
||||||
stars: z.number(),
|
stars: z.number(),
|
||||||
@ -42,45 +60,78 @@ const ZEarlyAdoptersResponse = z.record(
|
|||||||
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
|
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
|
||||||
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
|
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
|
||||||
|
|
||||||
export default async function OpenPage() {
|
const fetchGithubStats = async () => {
|
||||||
const {
|
return await fetch('https://api.github.com/repos/documenso/documenso', {
|
||||||
forks_count: forksCount,
|
|
||||||
open_issues: openIssues,
|
|
||||||
stargazers_count: stargazersCount,
|
|
||||||
} = await fetch('https://api.github.com/repos/documenso/documenso', {
|
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/vnd.github.v3+json',
|
...GITHUB_HEADERS,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZGithubStatsResponse.parse(res));
|
.then((res) => ZGithubStatsResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
const { total_count: mergedPullRequests } = await fetch(
|
const fetchOpenIssues = async () => {
|
||||||
|
return await fetch(
|
||||||
|
'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1',
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...GITHUB_HEADERS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(async (res) => res.json())
|
||||||
|
.then((res) => ZOpenIssuesResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMergedPullRequests = async () => {
|
||||||
|
return await fetch(
|
||||||
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
|
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/vnd.github.v3+json',
|
...GITHUB_HEADERS,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
.then((res) => ZMergedPullRequestsResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
const fetchStargazers = async () => {
|
||||||
|
return await fetch('https://stargrazer-live.onrender.com/api/stats', {
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZStargazersLiveResponse.parse(res));
|
.then((res) => ZStargazersLiveResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
const EARLY_ADOPTERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
|
const fetchEarlyAdopters = async () => {
|
||||||
|
return await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
|
||||||
headers: {
|
headers: {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (res) => res.json())
|
.then(async (res) => res.json())
|
||||||
.then((res) => ZEarlyAdoptersResponse.parse(res));
|
.then((res) => ZEarlyAdoptersResponse.parse(res));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OpenPage() {
|
||||||
|
const [
|
||||||
|
{ forks_count: forksCount, stargazers_count: stargazersCount },
|
||||||
|
{ total_count: openIssues },
|
||||||
|
{ total_count: mergedPullRequests },
|
||||||
|
STARGAZERS_DATA,
|
||||||
|
EARLY_ADOPTERS_DATA,
|
||||||
|
] = await Promise.all([
|
||||||
|
fetchGithubStats(),
|
||||||
|
fetchOpenIssues(),
|
||||||
|
fetchMergedPullRequests(),
|
||||||
|
fetchStargazers(),
|
||||||
|
fetchEarlyAdopters(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MONTHLY_USERS = await getUserMonthlyGrowth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
<div className="mx-auto mt-6 max-w-screen-lg sm:mt-12">
|
||||||
@ -122,7 +173,7 @@ export default async function OpenPage() {
|
|||||||
|
|
||||||
<TeamMembers className="col-span-12" />
|
<TeamMembers className="col-span-12" />
|
||||||
|
|
||||||
<SalaryBands className="col-span-12 lg:col-span-6" />
|
<SalaryBands className="col-span-12" />
|
||||||
|
|
||||||
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
|
<FundingRaised data={FUNDING_RAISED} className="col-span-12 lg:col-span-6" />
|
||||||
|
|
||||||
@ -172,6 +223,9 @@ export default async function OpenPage() {
|
|||||||
className="col-span-12 lg:col-span-6"
|
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" />
|
||||||
|
|
||||||
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
<div className="col-span-12 mt-12 flex flex-col items-center justify-center">
|
||||||
<h2 className="text-2xl font-bold">Where's the rest?</h2>
|
<h2 className="text-2xl font-bold">Where's the rest?</h2>
|
||||||
|
|
||||||
|
|||||||
@ -23,8 +23,8 @@ export function OpenPageTooltip() {
|
|||||||
<path
|
<path
|
||||||
d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82707 7.49972C1.82707 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82707 10.6327 1.82707 7.49972ZM8.24992 4.49999C8.24992 4.9142 7.91413 5.24999 7.49992 5.24999C7.08571 5.24999 6.74992 4.9142 6.74992 4.49999C6.74992 4.08577 7.08571 3.74999 7.49992 3.74999C7.91413 3.74999 8.24992 4.08577 8.24992 4.49999ZM6.00003 5.99999H6.50003H7.50003C7.77618 5.99999 8.00003 6.22384 8.00003 6.49999V9.99999H8.50003H9.00003V11H8.50003H7.50003H6.50003H6.00003V9.99999H6.50003H7.00003V6.99999H6.50003H6.00003V5.99999Z"
|
d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82707 7.49972C1.82707 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82707 10.6327 1.82707 7.49972ZM8.24992 4.49999C8.24992 4.9142 7.91413 5.24999 7.49992 5.24999C7.08571 5.24999 6.74992 4.9142 6.74992 4.49999C6.74992 4.08577 7.08571 3.74999 7.49992 3.74999C7.91413 3.74999 8.24992 4.08577 8.24992 4.49999ZM6.00003 5.99999H6.50003H7.50003C7.77618 5.99999 8.00003 6.22384 8.00003 6.49999V9.99999H8.50003H9.00003V11H8.50003H7.50003H6.50003H6.00003V9.99999H6.50003H7.00003V6.99999H6.50003H6.00003V5.99999Z"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
fill-rule="evenodd"
|
fillRule="evenodd"
|
||||||
clip-rule="evenodd"
|
clipRule="evenodd"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@ -5,13 +5,12 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
|
||||||
import { useTheme } from 'next-themes';
|
|
||||||
import { FaXTwitter } from 'react-icons/fa6';
|
import { FaXTwitter } from 'react-icons/fa6';
|
||||||
import { LiaDiscord } from 'react-icons/lia';
|
import { LiaDiscord } from 'react-icons/lia';
|
||||||
import { LuGithub } from 'react-icons/lu';
|
import { LuGithub } from 'react-icons/lu';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
|
||||||
|
|
||||||
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
export type FooterProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
@ -34,8 +33,6 @@ const FOOTER_LINKS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const Footer = ({ className, ...props }: FooterProps) => {
|
export const Footer = ({ className, ...props }: FooterProps) => {
|
||||||
const { setTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('border-t py-12', className)} {...props}>
|
<div className={cn('border-t py-12', className)} {...props}>
|
||||||
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
|
||||||
@ -77,21 +74,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24">
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
|
<div className="flex flex-wrap">
|
||||||
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
|
<ThemeSwitcher />
|
||||||
<Sun className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Light</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
|
|
||||||
<Moon className="h-5 w-5" />
|
|
||||||
<span className="sr-only">Dark</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/cons
|
|||||||
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
|
||||||
import { alphaid } from '@documenso/lib/universal/id';
|
import { alphaid } from '@documenso/lib/universal/id';
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import {
|
import {
|
||||||
DocumentDataType,
|
DocumentDataType,
|
||||||
@ -24,6 +25,7 @@ import {
|
|||||||
SendStatus,
|
SendStatus,
|
||||||
SigningStatus,
|
SigningStatus,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
const ZCreateSinglePlayerDocumentSchema = z.object({
|
const ZCreateSinglePlayerDocumentSchema = z.object({
|
||||||
documentData: z.object({
|
documentData: z.object({
|
||||||
@ -97,11 +99,13 @@ export const createSinglePlayerDocument = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBytes = await doc.save();
|
const unsignedPdfBytes = await doc.save();
|
||||||
|
|
||||||
const documentToken = await prisma.$transaction(
|
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
|
||||||
|
|
||||||
|
const { token } = await prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
const documentToken = alphaid();
|
const token = alphaid();
|
||||||
|
|
||||||
// Fetch service user who will be the owner of the document.
|
// Fetch service user who will be the owner of the document.
|
||||||
const serviceUser = await tx.user.findFirstOrThrow({
|
const serviceUser = await tx.user.findFirstOrThrow({
|
||||||
@ -110,14 +114,10 @@ export const createSinglePlayerDocument = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentDataBytes = Buffer.from(pdfBytes).toString('base64');
|
const { id: documentDataId } = await putFile({
|
||||||
|
name: `${documentName}.pdf`,
|
||||||
const { id: documentDataId } = await tx.documentData.create({
|
type: 'application/pdf',
|
||||||
data: {
|
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
|
||||||
type: DocumentDataType.BYTES_64,
|
|
||||||
data: documentDataBytes,
|
|
||||||
initialData: documentDataBytes,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create document.
|
// Create document.
|
||||||
@ -137,7 +137,7 @@ export const createSinglePlayerDocument = async (
|
|||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
name: signer.name,
|
name: signer.name,
|
||||||
email: signer.email,
|
email: signer.email,
|
||||||
token: documentToken,
|
token,
|
||||||
signedAt: createdAt,
|
signedAt: createdAt,
|
||||||
readStatus: ReadStatus.OPENED,
|
readStatus: ReadStatus.OPENED,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
@ -169,7 +169,7 @@ export const createSinglePlayerDocument = async (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return documentToken;
|
return { document, token };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
maxWait: 5000,
|
maxWait: 5000,
|
||||||
@ -195,10 +195,10 @@ export const createSinglePlayerDocument = async (
|
|||||||
subject: 'Document signed',
|
subject: 'Document signed',
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
attachments: [{ content: Buffer.from(pdfBytes), filename: documentName }],
|
attachments: [{ content: signedPdfBuffer, filename: documentName }],
|
||||||
});
|
});
|
||||||
|
|
||||||
return documentToken;
|
return token;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -12,6 +12,7 @@ ENV_FILES.forEach((file) => {
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActionsBodySizeLimit: '50mb',
|
serverActionsBodySizeLimit: '50mb',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"lint:fix": "next lint --fix",
|
||||||
"clean": "rimraf .next && rimraf node_modules",
|
"clean": "rimraf .next && rimraf node_modules",
|
||||||
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
|
||||||
},
|
},
|
||||||
@ -36,11 +37,13 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-icons": "^4.11.0",
|
"react-icons": "^4.11.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||||
|
|
||||||
import { AdminNav } from './nav';
|
import { AdminNav } from './nav';
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { ChevronLeft, Users2 } from 'lucide-react';
|
import { ChevronLeft, Users2 } from 'lucide-react';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||||
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
|
||||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||||
|
|||||||
@ -0,0 +1,185 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { History } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
|
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
|
||||||
|
|
||||||
|
const FORM_ID = 'resend-email';
|
||||||
|
|
||||||
|
export type ResendDocumentActionItemProps = {
|
||||||
|
document: Document;
|
||||||
|
recipients: Recipient[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZResendDocumentFormSchema = z.object({
|
||||||
|
recipients: z.array(z.number()).min(1, {
|
||||||
|
message: 'You must select at least one item.',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
||||||
|
|
||||||
|
export const ResendDocumentActionItem = ({
|
||||||
|
document,
|
||||||
|
recipients,
|
||||||
|
}: ResendDocumentActionItemProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const isDisabled =
|
||||||
|
document.status !== 'PENDING' ||
|
||||||
|
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||||
|
|
||||||
|
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<TResendDocumentFormSchema>({
|
||||||
|
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
recipients: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||||
|
try {
|
||||||
|
await resendDocument({ documentId: document.id, recipients });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Document re-sent',
|
||||||
|
description: 'Your document has been re-sent successfully.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'This document could not be re-sent at this time. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||||
|
<History className="mr-2 h-4 w-4" />
|
||||||
|
Resend
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-sm" hideClose>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<h1 className="text-center text-xl">Who do you want to remind?</h1>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="recipients"
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<>
|
||||||
|
{recipients.map((recipient) => (
|
||||||
|
<FormItem
|
||||||
|
key={recipient.id}
|
||||||
|
className="flex flex-row items-center justify-between gap-x-3"
|
||||||
|
>
|
||||||
|
<FormLabel
|
||||||
|
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||||
|
'opacity-50': !value.includes(recipient.id),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<StackAvatar
|
||||||
|
key={recipient.id}
|
||||||
|
type={getRecipientType(recipient)}
|
||||||
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
|
/>
|
||||||
|
{recipient.email}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
|
||||||
|
checkClassName="text-white"
|
||||||
|
value={recipient.id}
|
||||||
|
checked={value.includes(recipient.id)}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
checked
|
||||||
|
? onChange([...value, recipient.id])
|
||||||
|
: onChange(value.filter((v) => v !== recipient.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||||
|
Send reminder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -6,14 +6,9 @@ import { Edit, Pencil, Share } from 'lucide-react';
|
|||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
|
|
||||||
import {
|
|
||||||
TOAST_DOCUMENT_SHARE_ERROR,
|
|
||||||
TOAST_DOCUMENT_SHARE_SUCCESS,
|
|
||||||
} from '@documenso/lib/constants/toast';
|
|
||||||
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
|
||||||
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type DataTableActionButtonProps = {
|
export type DataTableActionButtonProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@ -25,13 +20,6 @@ export type DataTableActionButtonProps = {
|
|||||||
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
|
|
||||||
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
|
|
||||||
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -70,18 +58,15 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.otherwise(() => (
|
.otherwise(() => (
|
||||||
<Button
|
<DocumentShareButton
|
||||||
className="w-24"
|
documentId={row.id}
|
||||||
loading={isCopyingShareLink}
|
token={recipient?.token}
|
||||||
onClick={async () =>
|
trigger={({ loading }) => (
|
||||||
createAndCopyShareLink({
|
<Button className="w-24" loading={loading}>
|
||||||
token: recipient?.token,
|
{!loading && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
||||||
documentId: row.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!isCopyingShareLink && <Share className="-ml-1 mr-2 h-4 w-4" />}
|
|
||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
History,
|
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@ -18,15 +17,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
|
|
||||||
import {
|
|
||||||
TOAST_DOCUMENT_SHARE_ERROR,
|
|
||||||
TOAST_DOCUMENT_SHARE_SUCCESS,
|
|
||||||
} from '@documenso/lib/constants/toast';
|
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||||
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -34,9 +30,10 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
|
import { ResendDocumentActionItem } from './_action-items/resend-document';
|
||||||
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
|
||||||
|
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
|
||||||
|
|
||||||
export type DataTableActionDropdownProps = {
|
export type DataTableActionDropdownProps = {
|
||||||
row: Document & {
|
row: Document & {
|
||||||
@ -48,14 +45,8 @@ export type DataTableActionDropdownProps = {
|
|||||||
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const { createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
|
|
||||||
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
|
|
||||||
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
@ -106,6 +97,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
window.URL.revokeObjectURL(link.href);
|
window.URL.revokeObjectURL(link.href);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger>
|
<DropdownMenuTrigger>
|
||||||
@ -134,7 +126,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
Download
|
Download
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem disabled>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@ -151,27 +143,20 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
|
|
||||||
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
<DropdownMenuLabel>Share</DropdownMenuLabel>
|
||||||
|
|
||||||
<DropdownMenuItem disabled>
|
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
|
||||||
<History className="mr-2 h-4 w-4" />
|
|
||||||
Resend
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DocumentShareButton
|
||||||
disabled={isDraft}
|
documentId={row.id}
|
||||||
onClick={async () =>
|
token={recipient?.token}
|
||||||
createAndCopyShareLink({
|
trigger={({ loading, disabled }) => (
|
||||||
token: recipient?.token,
|
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
|
||||||
documentId: row.id,
|
<div className="flex items-center">
|
||||||
})
|
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
|
||||||
}
|
|
||||||
>
|
|
||||||
{isCopyingShareLink ? (
|
|
||||||
<Loader className="mr-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Share className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Share
|
Share
|
||||||
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
||||||
{isDocumentDeletable && (
|
{isDocumentDeletable && (
|
||||||
@ -181,6 +166,13 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
onOpenChange={setDeleteDialogOpen}
|
onOpenChange={setDeleteDialogOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isDuplicateDialogOpen && (
|
||||||
|
<DuplicateDocumentDialog
|
||||||
|
id={row.id}
|
||||||
|
open={isDuplicateDialogOpen}
|
||||||
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,105 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
type DuplicateDocumentDialogProps = {
|
||||||
|
id: number;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DuplicateDocumentDialog = ({
|
||||||
|
id,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DuplicateDocumentDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentData = document?.documentData;
|
||||||
|
|
||||||
|
const { mutateAsync: duplicateDocument, isLoading: isDuplicateLoading } =
|
||||||
|
trpcReact.document.duplicateDocument.useMutation({
|
||||||
|
onSuccess: (newId) => {
|
||||||
|
router.push(`/documents/${newId}`);
|
||||||
|
toast({
|
||||||
|
title: 'Document Duplicated',
|
||||||
|
description: 'Your document has been successfully duplicated.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDuplicate = async () => {
|
||||||
|
try {
|
||||||
|
await duplicateDocument({ id });
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: 'Something went wrong',
|
||||||
|
description: 'This document could not be duplicated at this time. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 7500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Duplicate</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{!documentData || isLoading ? (
|
||||||
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
|
Loading Document...
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll ">
|
||||||
|
<LazyPDFViewer key={document?.id} documentData={documentData} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={isDuplicateLoading || isLoading}
|
||||||
|
loading={isDuplicateLoading}
|
||||||
|
onClick={onDuplicate}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||||
@ -8,7 +8,8 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||||
import { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
|
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
import { DocumentsDataTable } from './data-table';
|
import { DocumentsDataTable } from './data-table';
|
||||||
@ -32,7 +33,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
});
|
});
|
||||||
|
|
||||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||||
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
|
|
||||||
@ -45,6 +46,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
},
|
},
|
||||||
page,
|
page,
|
||||||
perPage,
|
perPage,
|
||||||
|
period,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getTabHref = (value: typeof status) => {
|
const getTabHref = (value: typeof status) => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
@ -22,6 +23,7 @@ export type UploadDocumentProps = {
|
|||||||
|
|
||||||
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
className="min-h-[40vh]"
|
className="min-h-[40vh]"
|
||||||
disabled={remaining.documents === 0}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,11 @@ import { getServerSession } from 'next-auth';
|
|||||||
|
|
||||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
|
import { CommandMenu } from '~/components/(dashboard)/common/command-menu';
|
||||||
import { Header } from '~/components/(dashboard)/layout/header';
|
import { Header } from '~/components/(dashboard)/layout/header';
|
||||||
|
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
|
|
||||||
@ -30,6 +32,8 @@ export default async function AuthenticatedDashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<NextAuthProvider session={session}>
|
<NextAuthProvider session={session}>
|
||||||
<LimitsProvider>
|
<LimitsProvider>
|
||||||
|
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||||
|
<CommandMenu />
|
||||||
<Header user={user} />
|
<Header user={user} />
|
||||||
|
|
||||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||||
|
|||||||
@ -5,8 +5,9 @@ import {
|
|||||||
getStripeCustomerById,
|
getStripeCustomerById,
|
||||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
|
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
|
||||||
export const createBillingPortal = async () => {
|
export const createBillingPortal = async () => {
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import {
|
|||||||
getStripeCustomerById,
|
getStripeCustomerById,
|
||||||
} from '@documenso/ee/server-only/stripe/get-customer';
|
} from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
|
||||||
export type CreateCheckoutOptions = {
|
export type CreateCheckoutOptions = {
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
|
||||||
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
|
||||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||||
|
|
||||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-2xl font-semibold">Billing</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
|
|||||||
@ -1,19 +1,5 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
export default function PasswordSettingsPage() {
|
||||||
|
redirect('/settings/security');
|
||||||
export default async function PasswordSettingsPage() {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Password</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import { ProfileForm } from '~/components/forms/profile';
|
import { ProfileForm } from '~/components/forms/profile';
|
||||||
|
|
||||||
@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||||
|
|
||||||
|
|||||||
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
|
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||||
|
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||||
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
|
|
||||||
|
export default async function SecuritySettingsPage() {
|
||||||
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">Security</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Here you can manage your password and security settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<PasswordForm user={user} className="max-w-xl" />
|
||||||
|
|
||||||
|
<hr className="mb-4 mt-8" />
|
||||||
|
|
||||||
|
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Add and manage your two factor security settings to add an extra layer of security to your
|
||||||
|
account!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 max-w-xl">
|
||||||
|
<h5 className="font-medium">Two-factor methods</h5>
|
||||||
|
|
||||||
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.twoFactorEnabled && (
|
||||||
|
<div className="mt-4 max-w-xl">
|
||||||
|
<h5 className="font-medium">Recovery methods</h5>
|
||||||
|
|
||||||
|
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -56,7 +56,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
|
|||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
(
|
(
|
||||||
<div tw="relative flex h-full w-full">
|
<div tw="relative flex h-full w-full bg-white">
|
||||||
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
{/* @ts-expect-error Lack of typing from ImageResponse */}
|
||||||
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
|
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
|
||||||
|
|
||||||
@ -149,6 +149,10 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
|
|||||||
weight: 600,
|
weight: 600,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|||||||
import { DATE_FORMATS } from '~/helpers/constants';
|
import { DATE_FORMATS } from '~/helpers/constants';
|
||||||
|
|
||||||
import { useRequiredSigningContext } from './provider';
|
import { useRequiredSigningContext } from './provider';
|
||||||
|
import { SignDialog } from './sign-dialog';
|
||||||
|
|
||||||
export type SigningFormProps = {
|
export type SigningFormProps = {
|
||||||
document: Document;
|
document: Document;
|
||||||
@ -57,6 +58,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
|
|
||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
setValidateUninsertedFields(true);
|
setValidateUninsertedFields(true);
|
||||||
|
|
||||||
const isFieldsValid = validateFieldsInserted(fields);
|
const isFieldsValid = validateFieldsInserted(fields);
|
||||||
|
|
||||||
if (!isFieldsValid) {
|
if (!isFieldsValid) {
|
||||||
@ -168,9 +170,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
|
<SignDialog
|
||||||
Complete
|
isSubmitting={isSubmitting}
|
||||||
</Button>
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
|
document={document}
|
||||||
|
fields={fields}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||||
import { NextAuthProvider } from '~/providers/next-auth';
|
import { NextAuthProvider } from '~/providers/next-auth';
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { notFound, redirect } from 'next/navigation';
|
|||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
|||||||
77
apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
Normal file
77
apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Document, Field } from '@documenso/prisma/client';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
export type SignDialogProps = {
|
||||||
|
isSubmitting: boolean;
|
||||||
|
document: Document;
|
||||||
|
fields: Field[];
|
||||||
|
onSignatureComplete: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignDialog = ({
|
||||||
|
isSubmitting,
|
||||||
|
document,
|
||||||
|
fields,
|
||||||
|
onSignatureComplete,
|
||||||
|
}: SignDialogProps) => {
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
|
const isComplete = fields.every((field) => field.inserted);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
disabled={!isComplete}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
Complete
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
|
||||||
|
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||||
|
You are about to finish signing "{document.title}". Are you sure?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDialog(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!isComplete}
|
||||||
|
loading={isSubmitting}
|
||||||
|
onClick={onSignatureComplete}
|
||||||
|
>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,7 +5,7 @@ import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
|||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-semibold">Forgotten your password?</h1>
|
<h1 className="text-4xl font-semibold">Forgot your password?</h1>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
No worries, it happens! Enter your email and we'll email you a special link to reset your
|
No worries, it happens! Enter your email and we'll email you a special link to reset your
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export default function SignInPage() {
|
|||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
className="text-muted-foreground text-sm duration-200 hover:opacity-70"
|
||||||
>
|
>
|
||||||
Forgotten your password?
|
Forgot your password?
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,97 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type PageProps = {
|
||||||
|
params: {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="mb-4 text-red-300">
|
||||||
|
<XOctagon />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-4xl font-semibold">No token provided</h2>
|
||||||
|
<p className="text-muted-foreground mt-2 text-base">
|
||||||
|
It seems that there is no token provided. Please check your email and try again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = await verifyEmail({ token });
|
||||||
|
|
||||||
|
if (verified === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
We were unable to verify your email. If your email is not verified already, please try
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
It seems that the provided token has expired. We've just sent you another token, please
|
||||||
|
check your email and try again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
Your email has been successfully confirmed! You can now use all features of Documenso.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/web/src/app/(unauthenticated)/verify-email/page.tsx
Normal file
28
apps/web/src/app/(unauthenticated)/verify-email/page.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export default function EmailVerificationWithoutTokenPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-start">
|
||||||
|
<div className="mr-4 mt-1 hidden md:block">
|
||||||
|
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-4">
|
||||||
|
It seems that there is no token provided, if you are trying to verify your email please
|
||||||
|
follow the link in your email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button className="mt-4" asChild>
|
||||||
|
<Link href="/">Go back home</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import NotFoundPartial from '~/components/partials/not-found';
|
import NotFoundPartial from '~/components/partials/not-found';
|
||||||
|
|||||||
133
apps/web/src/components/(dashboard)/common/command-menu.tsx
Normal file
133
apps/web/src/components/(dashboard)/common/command-menu.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DOCUMENTS_PAGE_SHORTCUT,
|
||||||
|
SETTINGS_PAGE_SHORTCUT,
|
||||||
|
} from '@documenso/lib/constants/keyboard-shortcuts';
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandShortcut,
|
||||||
|
} from '@documenso/ui/primitives/command';
|
||||||
|
|
||||||
|
const DOCUMENTS_PAGES = [
|
||||||
|
{
|
||||||
|
label: 'All documents',
|
||||||
|
path: '/documents?status=ALL',
|
||||||
|
shortcut: DOCUMENTS_PAGE_SHORTCUT.replace('+', ''),
|
||||||
|
},
|
||||||
|
{ label: 'Draft documents', path: '/documents?status=DRAFT' },
|
||||||
|
{ label: 'Completed documents', path: '/documents?status=COMPLETED' },
|
||||||
|
{ label: 'Pending documents', path: '/documents?status=PENDING' },
|
||||||
|
{ label: 'Inbox documents', path: '/documents?status=INBOX' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SETTINGS_PAGES = [
|
||||||
|
{ label: 'Settings', path: '/settings', shortcut: SETTINGS_PAGE_SHORTCUT.replace('+', '') },
|
||||||
|
{ label: 'Profile', path: '/settings/profile' },
|
||||||
|
{ label: 'Password', path: '/settings/password' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CommandMenu() {
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
const { push } = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
const currentPage = pages[pages.length - 1];
|
||||||
|
|
||||||
|
const toggleOpen = () => {
|
||||||
|
setOpen((open) => !open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
|
||||||
|
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
|
||||||
|
|
||||||
|
useHotkeys('ctrl+k', toggleOpen);
|
||||||
|
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
|
||||||
|
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
// Escape goes to previous page
|
||||||
|
// Backspace goes to previous page when search is empty
|
||||||
|
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage === undefined) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
setPages((pages) => pages.slice(0, -1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog commandProps={{ onKeyDown: handleKeyDown }} open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
placeholder="Type a command or search..."
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
{!currentPage && (
|
||||||
|
<>
|
||||||
|
<CommandGroup heading="Documents">
|
||||||
|
<Commands push={push} pages={DOCUMENTS_PAGES} />
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Settings">
|
||||||
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandGroup heading="Preferences">
|
||||||
|
<CommandItem onSelect={() => setPages([...pages, 'theme'])}>Change theme</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{currentPage === 'theme' && <ThemeCommands setTheme={setTheme} />}
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Commands = ({
|
||||||
|
push,
|
||||||
|
pages,
|
||||||
|
}: {
|
||||||
|
push: (_path: string) => void;
|
||||||
|
pages: { label: string; path: string; shortcut?: string }[];
|
||||||
|
}) => {
|
||||||
|
return pages.map((page) => (
|
||||||
|
<CommandItem key={page.path} onSelect={() => push(page.path)}>
|
||||||
|
{page.label}
|
||||||
|
{page.shortcut && <CommandShortcut>{page.shortcut}</CommandShortcut>}
|
||||||
|
</CommandItem>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
|
||||||
|
const THEMES = useMemo(
|
||||||
|
() => [
|
||||||
|
{ label: 'Light Mode', theme: 'light', icon: Sun },
|
||||||
|
{ label: 'Dark Mode', theme: 'dark', icon: Moon },
|
||||||
|
{ label: 'System Theme', theme: 'system', icon: Monitor },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return THEMES.map((theme) => (
|
||||||
|
<CommandItem key={theme.theme} onSelect={() => setTheme(theme.theme)}>
|
||||||
|
<theme.icon className="mr-2" />
|
||||||
|
{theme.label}
|
||||||
|
</CommandItem>
|
||||||
|
));
|
||||||
|
};
|
||||||
@ -4,7 +4,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Key,
|
Lock,
|
||||||
LogOut,
|
LogOut,
|
||||||
User as LucideUser,
|
User as LucideUser,
|
||||||
Monitor,
|
Monitor,
|
||||||
@ -87,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/password" className="cursor-pointer">
|
<Link href="/settings/security" className="cursor-pointer">
|
||||||
<Key className="mr-2 h-4 w-4" />
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
Password
|
Security
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ONE_SECOND } from '@documenso/lib/constants/time';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type VerifyEmailBannerProps = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
|
||||||
|
|
||||||
|
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
|
||||||
|
|
||||||
|
const { mutateAsync: sendConfirmationEmail, isLoading } =
|
||||||
|
trpc.profile.sendConfirmationEmail.useMutation();
|
||||||
|
|
||||||
|
const onResendConfirmationEmail = async () => {
|
||||||
|
try {
|
||||||
|
setIsButtonDisabled(true);
|
||||||
|
|
||||||
|
await sendConfirmationEmail({ email: email });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Verification email sent successfully.',
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
|
||||||
|
} catch (err) {
|
||||||
|
setIsButtonDisabled(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Something went wrong while sending the confirmation email.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check localStorage to see if we've recently automatically displayed the dialog
|
||||||
|
// if it was within the past 24 hours, don't show it again
|
||||||
|
// otherwise, show it again and update the localStorage timestamp
|
||||||
|
const emailVerificationDialogLastShown = localStorage.getItem(
|
||||||
|
'emailVerificationDialogLastShown',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (emailVerificationDialogLastShown) {
|
||||||
|
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||||
|
|
||||||
|
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
|
|
||||||
|
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-yellow-200 dark:bg-yellow-400">
|
||||||
|
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||||
|
Verify your email address to unlock all features.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>Verify your email address</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
|
||||||
|
click the link in the email to verify your account.
|
||||||
|
</DialogDescription>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
disabled={isButtonDisabled}
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={onResendConfirmationEmail}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { CreditCard, Lock, User } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/password">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Key className="mr-2 h-5 w-5" />
|
<Lock className="mr-2 h-5 w-5" />
|
||||||
Password
|
Security
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
import { CreditCard, Key, User } from 'lucide-react';
|
import { CreditCard, Lock, User } from 'lucide-react';
|
||||||
|
|
||||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/password">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Key className="mr-2 h-5 w-5" />
|
<Lock className="mr-2 h-5 w-5" />
|
||||||
Password
|
Security
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
'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="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p>Authenticator app</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||||
|
Create one-time passwords that serve as a secondary authentication method for confirming
|
||||||
|
your identity when requested during the sign-in process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isTwoFactorEnabled ? (
|
||||||
|
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setModalState('enable')} size="sm">
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EnableAuthenticatorAppDialog
|
||||||
|
key={isEnableDialogOpen ? 'open' : 'closed'}
|
||||||
|
open={isEnableDialogOpen}
|
||||||
|
onOpenChange={(open) => !open && setModalState(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DisableAuthenticatorAppDialog
|
||||||
|
key={isDisableDialogOpen ? 'open' : 'closed'}
|
||||||
|
open={isDisableDialogOpen}
|
||||||
|
onOpenChange={(open) => !open && setModalState(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export const ZDisableTwoFactorAuthenticationForm = z.object({
|
||||||
|
password: z.string().min(6).max(72),
|
||||||
|
backupCode: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDisableTwoFactorAuthenticationForm = z.infer<
|
||||||
|
typeof ZDisableTwoFactorAuthenticationForm
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DisableAuthenticatorAppDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisableAuthenticatorAppDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DisableAuthenticatorAppDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: disableTwoFactorAuthentication } =
|
||||||
|
trpc.twoFactorAuthentication.disable.useMutation();
|
||||||
|
|
||||||
|
const disableTwoFactorAuthenticationForm = useForm<TDisableTwoFactorAuthenticationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
backupCode: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZDisableTwoFactorAuthenticationForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } =
|
||||||
|
disableTwoFactorAuthenticationForm.formState;
|
||||||
|
|
||||||
|
const onDisableTwoFactorAuthenticationFormSubmit = async ({
|
||||||
|
password,
|
||||||
|
backupCode,
|
||||||
|
}: TDisableTwoFactorAuthenticationForm) => {
|
||||||
|
try {
|
||||||
|
await disableTwoFactorAuthentication({ password, backupCode });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Two-factor authentication disabled',
|
||||||
|
description:
|
||||||
|
'Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.',
|
||||||
|
});
|
||||||
|
|
||||||
|
flushSync(() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to disable two-factor authentication',
|
||||||
|
description:
|
||||||
|
'We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Disable Authenticator App</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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...disableTwoFactorAuthenticationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
||||||
|
onDisableTwoFactorAuthenticationFormSubmit,
|
||||||
|
)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={disableTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDisableTwoFactorAuthenticationSubmitting}
|
||||||
|
>
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,283 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { renderSVG } from 'uqr';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
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({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
||||||
|
|
||||||
|
export type EnableAuthenticatorAppDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (_open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnableAuthenticatorAppDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: EnableAuthenticatorAppDialogProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } =
|
||||||
|
trpc.twoFactorAuthentication.setup.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } =
|
||||||
|
trpc.twoFactorAuthentication.enable.useMutation();
|
||||||
|
|
||||||
|
const setupTwoFactorAuthenticationForm = useForm<TSetupTwoFactorAuthenticationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
||||||
|
setupTwoFactorAuthenticationForm.formState;
|
||||||
|
|
||||||
|
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZEnableTwoFactorAuthenticationForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } =
|
||||||
|
enableTwoFactorAuthenticationForm.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) => {
|
||||||
|
try {
|
||||||
|
await setupTwoFactorAuthentication({ password });
|
||||||
|
} 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 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 onCompleteClick = () => {
|
||||||
|
flushSync(() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{step === 'setup' && (
|
||||||
|
<DialogDescription>
|
||||||
|
To enable two-factor authentication, please enter your password below.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'view' && (
|
||||||
|
<DialogDescription>
|
||||||
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
))
|
||||||
|
.with('view', () => (
|
||||||
|
<div>
|
||||||
|
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
||||||
|
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
||||||
|
<Button type="button" onClick={() => onCompleteClick()}>
|
||||||
|
Complete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Copy } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type RecoveryCodeListProps = {
|
||||||
|
recoveryCodes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [, copyToClipboard] = useCopyToClipboard();
|
||||||
|
|
||||||
|
const onCopyRecoveryCodeClick = async (code: string) => {
|
||||||
|
try {
|
||||||
|
const result = await copyToClipboard(code);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Unable to copy recovery code');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Recovery code copied',
|
||||||
|
description: 'Your recovery code has been copied to your clipboard.',
|
||||||
|
});
|
||||||
|
} catch (_err) {
|
||||||
|
toast({
|
||||||
|
title: 'Unable to copy recovery code',
|
||||||
|
description:
|
||||||
|
'We were unable to copy your recovery code to your clipboard. Please try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{recoveryCodes.map((code) => (
|
||||||
|
<div
|
||||||
|
key={code}
|
||||||
|
className="bg-muted text-muted-foreground relative rounded-lg p-4 font-mono md:text-center"
|
||||||
|
>
|
||||||
|
<span>{code}</span>
|
||||||
|
|
||||||
|
<div className="absolute inset-y-0 right-4 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
className="opacity-60 hover:opacity-80"
|
||||||
|
onClick={() => void onCopyRecoveryCodeClick(code)}
|
||||||
|
>
|
||||||
|
<Copy className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||||
|
|
||||||
|
type RecoveryCodesProps = {
|
||||||
|
// backupCodes: string[] | null;
|
||||||
|
isTwoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p>Recovery Codes</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||||
|
Recovery codes are used to access your account in the event that you lose access to your
|
||||||
|
authenticator app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
||||||
|
View Codes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ViewRecoveryCodesDialog
|
||||||
|
key={isOpen ? 'open' : 'closed'}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
|
export const ZViewRecoveryCodesForm = z.object({
|
||||||
|
password: z.string().min(6).max(72),
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } =
|
||||||
|
trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation();
|
||||||
|
|
||||||
|
const viewRecoveryCodesForm = useForm<TViewRecoveryCodesForm>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZViewRecoveryCodesForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState;
|
||||||
|
|
||||||
|
const step = useMemo(() => {
|
||||||
|
if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) {
|
||||||
|
return 'authenticate';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'view';
|
||||||
|
}, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]);
|
||||||
|
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||||
|
|
||||||
|
{step === 'authenticate' && (
|
||||||
|
<DialogDescription>
|
||||||
|
To view your recovery codes, please enter your password below.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'view' && (
|
||||||
|
<DialogDescription>
|
||||||
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with('view', () => (
|
||||||
|
<div>
|
||||||
|
{viewRecoveryCodesData?.recoveryCodes && (
|
||||||
|
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,8 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||||
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
|
||||||
|
|
||||||
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
export type AddFieldsActionInput = TAddFieldsFormSchema & {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||||
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
|
||||||
|
|
||||||
export type AddSignersActionInput = TAddSignersFormSchema & {
|
export type AddSignersActionInput = TAddSignersFormSchema & {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||||
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
|
||||||
|
|
||||||
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
@ -12,23 +11,30 @@ import { z } from 'zod';
|
|||||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input, PasswordInput } from '@documenso/ui/primitives/input';
|
||||||
import { Label } from '@documenso/ui/primitives/label';
|
import { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ERROR_MESSAGES = {
|
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||||
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||||
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||||
[ErrorCode.USER_MISSING_PASSWORD]:
|
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||||
'This account appears to be using a social login method, please sign in using that method',
|
'This account appears to be using a social login method, please sign in using that method',
|
||||||
|
[ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect',
|
||||||
|
[ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS;
|
||||||
|
|
||||||
const LOGIN_REDIRECT_PATH = '/documents';
|
const LOGIN_REDIRECT_PATH = '/documents';
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(6).max(72),
|
password: z.string().min(6).max(72),
|
||||||
|
totpCode: z.string().trim().optional(),
|
||||||
|
backupCode: z.string().trim().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||||
@ -39,33 +45,84 @@ export type SignInFormProps = {
|
|||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
|
'totp' | 'backup'
|
||||||
|
>('totp');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<TSignInFormSchema>({
|
} = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
totpCode: '',
|
||||||
|
backupCode: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZSignInFormSchema),
|
resolver: zodResolver(ZSignInFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = async ({ email, password }: TSignInFormSchema) => {
|
const onCloseTwoFactorAuthenticationDialog = () => {
|
||||||
|
setValue('totpCode', '');
|
||||||
|
setValue('backupCode', '');
|
||||||
|
|
||||||
|
setIsTwoFactorAuthenticationDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleTwoFactorAuthenticationMethodClick = () => {
|
||||||
|
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
|
||||||
|
|
||||||
|
if (method === 'totp') {
|
||||||
|
setValue('backupCode', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'backup') {
|
||||||
|
setValue('totpCode', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTwoFactorAuthenticationMethod(method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
|
||||||
try {
|
try {
|
||||||
const result = await signIn('credentials', {
|
const credentials: Record<string, string> = {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totpCode) {
|
||||||
|
credentials.totpCode = totpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupCode) {
|
||||||
|
credentials.backupCode = backupCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
...credentials,
|
||||||
|
|
||||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error && isErrorCode(result.error)) {
|
if (result?.error && isErrorCode(result.error)) {
|
||||||
|
if (result.error === TwoFactorEnabledErrorCode) {
|
||||||
|
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = ERROR_MESSAGES[result.error];
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: ERROR_MESSAGES[result.error],
|
title: 'Unable to sign in',
|
||||||
|
description: errorMessage ?? 'An unknown error occurred',
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -118,32 +175,15 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<span>Password</span>
|
<span>Password</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className="relative">
|
<PasswordInput
|
||||||
<Input
|
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
minLength={6}
|
minLength={6}
|
||||||
maxLength={72}
|
maxLength={72}
|
||||||
|
className="bg-background mt-2"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className="bg-background mt-2 pr-10"
|
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
type="button"
|
|
||||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
|
||||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
|
||||||
onClick={() => setShowPassword((show) => !show)}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="text-muted-foreground h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -173,6 +213,67 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isTwoFactorAuthenticationDialogOpen}
|
||||||
|
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
{twoFactorAuthenticationMethod === 'totp' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="totpCode" className="text-muted-forground">
|
||||||
|
Authentication Token
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="totpCode"
|
||||||
|
type="text"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
{...register('totpCode')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.totpCode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{twoFactorAuthenticationMethod === 'backup' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="backupCode" className="text-muted-forground">
|
||||||
|
Backup Code
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="backupCode"
|
||||||
|
type="text"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
{...register('backupCode')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
||||||
|
>
|
||||||
|
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isSubmitting}>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,53 +1,86 @@
|
|||||||
|
###########################
|
||||||
|
# BASE CONTAINER #
|
||||||
|
###########################
|
||||||
FROM node:18-alpine AS base
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
# Install dependencies only when needed
|
###########################
|
||||||
FROM base AS production_deps
|
# BUILDER CONTAINER #
|
||||||
WORKDIR /app
|
###########################
|
||||||
|
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
|
|
||||||
# Copy our current monorepo
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN npm ci --production
|
|
||||||
|
|
||||||
# Install dependencies only when needed
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
|
RUN apk add --no-cache jq
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy our current monorepo
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN TURBO_VERSION="$(npm list --package-lock-only --json turbo | jq -r '.dependencies.turbo.version')"
|
||||||
|
RUN npm install -g "turbo@$TURBO_VERSION"
|
||||||
|
|
||||||
|
# Outputs to the /out folder
|
||||||
|
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
|
||||||
|
RUN turbo prune --scope=@documenso/web --docker
|
||||||
|
|
||||||
|
###########################
|
||||||
|
# INSTALLER CONTAINER #
|
||||||
|
###########################
|
||||||
|
FROM base AS installer
|
||||||
|
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
RUN apk add --no-cache jq
|
||||||
|
# Required for node_modules/aws-crt
|
||||||
|
RUN apk add --no-cache make cmake g++
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
# Disable husky from installing hooks
|
# Disable husky from installing hooks
|
||||||
ENV HUSKY 0
|
ENV HUSKY 0
|
||||||
|
ENV DOCKER_OUTPUT 1
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Uncomment and use build args to enable remote caching
|
||||||
|
# ARG TURBO_TEAM
|
||||||
|
# ENV TURBO_TEAM=$TURBO_TEAM
|
||||||
|
# ARG TURBO_TOKEN
|
||||||
|
# ENV TURBO_TOKEN=$TURBO_TOKEN
|
||||||
|
|
||||||
|
# First install the dependencies (as they change less often)
|
||||||
|
COPY .gitignore .gitignore
|
||||||
|
COPY --from=builder /app/out/json/ .
|
||||||
|
COPY --from=builder /app/out/package-lock.json ./package-lock.json
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
RUN npm run build --workspaces
|
# Then copy all the source code (as it changes more often)
|
||||||
|
COPY --from=builder /app/out/full/ .
|
||||||
|
# Finally copy the turbo.json file so that we can run turbo commands
|
||||||
|
COPY turbo.json turbo.json
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
RUN TURBO_VERSION="$(npm list --package-lock-only --json turbo | jq -r '.dependencies.turbo.version')"
|
||||||
|
RUN npm install -g "turbo@$TURBO_VERSION"
|
||||||
|
|
||||||
|
RUN turbo run build --filter=@documenso/web...
|
||||||
|
|
||||||
|
###########################
|
||||||
|
# RUNNER CONTAINER #
|
||||||
|
###########################
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
# Don't run production as root
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
COPY --from=production_deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
COPY --from=installer /app/apps/web/next.config.js .
|
||||||
COPY --from=production_deps --chown=nextjs:nodejs /app/package-lock.json ./package-lock.json
|
COPY --from=installer /app/apps/web/package.json .
|
||||||
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./package.json
|
# Automatically leverage output traces to reduce image size
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./.next
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||||
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||||
|
|
||||||
EXPOSE 3000
|
CMD node apps/web/server.js
|
||||||
|
|
||||||
ENV PORT 3000
|
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
|
||||||
|
|||||||
32
docker/compose-test.yml
Normal file
32
docker/compose-test.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
name: documenso_test
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=documenso
|
||||||
|
- POSTGRES_PASSWORD=password
|
||||||
|
- POSTGRES_DB=documenso
|
||||||
|
ports:
|
||||||
|
- 54322:5432
|
||||||
|
|
||||||
|
inbucket:
|
||||||
|
image: inbucket/inbucket
|
||||||
|
# ports:
|
||||||
|
# - 9000:9000
|
||||||
|
# - 2500:2500
|
||||||
|
# - 1100:1100
|
||||||
|
|
||||||
|
documenso:
|
||||||
|
build:
|
||||||
|
context: ../
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- database
|
||||||
|
- inbucket
|
||||||
|
env_file:
|
||||||
|
- ../.env.example
|
||||||
|
environment:
|
||||||
|
- NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
||||||
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
||||||
|
ports:
|
||||||
|
- 3000:3000
|
||||||
1761
package-lock.json
generated
1761
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@
|
|||||||
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
|
||||||
"start": "cd apps && cd web && next start",
|
"start": "cd apps && cd web && next start",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
|
"lint:fix": "turbo run lint:fix",
|
||||||
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"commitlint": "commitlint --edit",
|
"commitlint": "commitlint --edit",
|
||||||
@ -46,6 +47,7 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"recharts": "^2.7.2"
|
"recharts": "^2.7.2",
|
||||||
|
"react-hotkeys-hook": "^4.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export const limitsHandler = async (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({
|
return res.status(500).json({
|
||||||
error: ERROR_CODES.UNKNOWN,
|
error: ERROR_CODES.UNKNOWN,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,11 +17,11 @@
|
|||||||
"worker:test": "tsup worker/index.ts --format esm"
|
"worker:test": "tsup worker/index.ts --format esm"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@documenso/nodemailer-resend": "1.0.0",
|
"@documenso/nodemailer-resend": "2.0.0",
|
||||||
"@react-email/components": "^0.0.7",
|
"@react-email/components": "^0.0.11",
|
||||||
"nodemailer": "^6.9.3",
|
"nodemailer": "^6.9.3",
|
||||||
"react-email": "^1.9.4",
|
"react-email": "^1.9.5",
|
||||||
"resend": "^1.1.0"
|
"resend": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@documenso/tailwind-config": "*",
|
"@documenso/tailwind-config": "*",
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
||||||
|
|
||||||
|
import * as config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
|
export type TemplateConfirmationEmailProps = {
|
||||||
|
confirmationLink: string;
|
||||||
|
assetBaseUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateConfirmationEmail = ({
|
||||||
|
confirmationLink,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: TemplateConfirmationEmailProps) => {
|
||||||
|
return (
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
|
<Section className="flex-row items-center justify-center">
|
||||||
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
|
Welcome to Documenso!
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
|
Before you get started, please confirm your email address by clicking the button below:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section className="mb-6 mt-8 text-center">
|
||||||
|
<Button
|
||||||
|
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||||
|
href={confirmationLink}
|
||||||
|
>
|
||||||
|
Confirm email
|
||||||
|
</Button>
|
||||||
|
<Text className="mt-8 text-center text-sm italic text-slate-400">
|
||||||
|
You can also copy and paste this link into your browser: {confirmationLink} (link
|
||||||
|
expires in 1 hour)
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
</Tailwind>
|
||||||
|
);
|
||||||
|
};
|
||||||
69
packages/email/templates/confirm-email.tsx
Normal file
69
packages/email/templates/confirm-email.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
} from '@react-email/components';
|
||||||
|
|
||||||
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TemplateConfirmationEmail,
|
||||||
|
TemplateConfirmationEmailProps,
|
||||||
|
} from '../template-components/template-confirmation-email';
|
||||||
|
import { TemplateFooter } from '../template-components/template-footer';
|
||||||
|
|
||||||
|
export const ConfirmEmailTemplate = ({
|
||||||
|
confirmationLink,
|
||||||
|
assetBaseUrl,
|
||||||
|
}: TemplateConfirmationEmailProps) => {
|
||||||
|
const previewText = `Please confirm your email address`;
|
||||||
|
|
||||||
|
const getAssetUrl = (path: string) => {
|
||||||
|
return new URL(path, assetBaseUrl).toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: config.theme.extend.colors,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Body className="mx-auto my-auto bg-white font-sans">
|
||||||
|
<Section>
|
||||||
|
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||||
|
<Section>
|
||||||
|
<Img
|
||||||
|
src={getAssetUrl('/static/logo.png')}
|
||||||
|
alt="Documenso Logo"
|
||||||
|
className="mb-4 h-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TemplateConfirmationEmail
|
||||||
|
confirmationLink={confirmationLink}
|
||||||
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
<div className="mx-auto mt-12 max-w-xl" />
|
||||||
|
|
||||||
|
<Container className="mx-auto max-w-xl">
|
||||||
|
<TemplateFooter isDocument={false} />
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -2,14 +2,13 @@ module.exports = {
|
|||||||
extends: [
|
extends: [
|
||||||
'next',
|
'next',
|
||||||
'turbo',
|
'turbo',
|
||||||
'prettier',
|
|
||||||
'eslint:recommended',
|
'eslint:recommended',
|
||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:prettier/recommended',
|
'plugin:prettier/recommended',
|
||||||
'plugin:package-json/recommended',
|
'plugin:package-json/recommended',
|
||||||
],
|
],
|
||||||
|
|
||||||
plugins: ['prettier', 'package-json'],
|
plugins: ['prettier', 'package-json', 'unused-imports'],
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
@ -30,12 +29,22 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
|
'@next/next/no-html-link-for-pages': 'off',
|
||||||
'react/no-unescaped-entities': 'off',
|
'react/no-unescaped-entities': 'off',
|
||||||
|
|
||||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
'unused-imports/no-unused-imports': 'warn',
|
||||||
|
'unused-imports/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
vars: 'all',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
args: 'after-used',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
destructuredArrayIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
'no-duplicate-imports': 'error',
|
|
||||||
'no-multi-spaces': [
|
'no-multi-spaces': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
@ -67,5 +76,14 @@ module.exports = {
|
|||||||
// To handle this we want this rule to catch usages and highlight them as
|
// To handle this we want this rule to catch usages and highlight them as
|
||||||
// warnings so we can write appropriate interfaces and guards later.
|
// warnings so we can write appropriate interfaces and guards later.
|
||||||
'@typescript-eslint/consistent-type-assertions': ['warn', { assertionStyle: 'never' }],
|
'@typescript-eslint/consistent-type-assertions': ['warn', { assertionStyle: 'never' }],
|
||||||
|
|
||||||
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
prefer: 'type-imports',
|
||||||
|
fixStyle: 'separate-type-imports',
|
||||||
|
disallowTypeAnnotations: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
"eslint-plugin-package-json": "^0.1.4",
|
"eslint-plugin-package-json": "^0.1.4",
|
||||||
"eslint-plugin-prettier": "^4.2.1",
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
|
"eslint-plugin-unused-imports": "^3.0.0",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/lib/constants/crypto.ts
Normal file
1
packages/lib/constants/crypto.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||||
2
packages/lib/constants/keyboard-shortcuts.ts
Normal file
2
packages/lib/constants/keyboard-shortcuts.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const SETTINGS_PAGE_SHORTCUT = 'N+S';
|
||||||
|
export const DOCUMENTS_PAGE_SHORTCUT = 'N+D';
|
||||||
@ -7,6 +7,8 @@ import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
|
|||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
|
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
import { ErrorCode } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
@ -22,13 +24,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
credentials: {
|
credentials: {
|
||||||
email: { label: 'Email', type: 'email' },
|
email: { label: 'Email', type: 'email' },
|
||||||
password: { label: 'Password', type: 'password' },
|
password: { label: 'Password', type: 'password' },
|
||||||
|
totpCode: {
|
||||||
|
label: 'Two-factor Code',
|
||||||
|
type: 'input',
|
||||||
|
placeholder: 'Code from authenticator app',
|
||||||
|
},
|
||||||
|
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||||
},
|
},
|
||||||
authorize: async (credentials, _req) => {
|
authorize: async (credentials, _req) => {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = credentials;
|
const { email, password, backupCode, totpCode } = credentials;
|
||||||
|
|
||||||
const user = await getUserByEmail({ email }).catch(() => {
|
const user = await getUserByEmail({ email }).catch(() => {
|
||||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
@ -44,6 +52,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||||
|
|
||||||
|
if (is2faEnabled) {
|
||||||
|
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error(
|
||||||
|
totpCode
|
||||||
|
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||||
|
: ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(user.id),
|
id: Number(user.id),
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -88,11 +110,13 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
merged.id = retrieved.id;
|
merged.id = retrieved.id;
|
||||||
merged.name = retrieved.name;
|
merged.name = retrieved.name;
|
||||||
merged.email = retrieved.email;
|
merged.email = retrieved.email;
|
||||||
|
merged.emailVerified = retrieved.emailVerified;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!merged.lastSignedIn ||
|
merged.id &&
|
||||||
DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now()
|
(!merged.lastSignedIn ||
|
||||||
|
DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now())
|
||||||
) {
|
) {
|
||||||
merged.lastSignedIn = new Date().toISOString();
|
merged.lastSignedIn = new Date().toISOString();
|
||||||
|
|
||||||
@ -111,6 +135,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
name: merged.name,
|
name: merged.name,
|
||||||
email: merged.email,
|
email: merged.email,
|
||||||
lastSignedIn: merged.lastSignedIn,
|
lastSignedIn: merged.lastSignedIn,
|
||||||
|
emailVerified: merged.emailVerified,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -122,6 +147,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
id: Number(token.id),
|
id: Number(token.id),
|
||||||
name: token.name,
|
name: token.name,
|
||||||
email: token.email,
|
email: token.email,
|
||||||
|
emailVerified:
|
||||||
|
typeof token.emailVerified === 'string' ? new Date(token.emailVerified) : null,
|
||||||
},
|
},
|
||||||
} satisfies Session;
|
} satisfies Session;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,4 +8,15 @@ export const ErrorCode = {
|
|||||||
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
||||||
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
||||||
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
||||||
|
INTERNAL_SEVER_ERROR: 'INTERNAL_SEVER_ERROR',
|
||||||
|
TWO_FACTOR_ALREADY_ENABLED: 'TWO_FACTOR_ALREADY_ENABLED',
|
||||||
|
TWO_FACTOR_SETUP_REQUIRED: 'TWO_FACTOR_SETUP_REQUIRED',
|
||||||
|
TWO_FACTOR_MISSING_SECRET: 'TWO_FACTOR_MISSING_SECRET',
|
||||||
|
TWO_FACTOR_MISSING_CREDENTIALS: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||||
|
INCORRECT_TWO_FACTOR_CODE: 'INCORRECT_TWO_FACTOR_CODE',
|
||||||
|
INCORRECT_TWO_FACTOR_BACKUP_CODE: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||||
|
INCORRECT_IDENTITY_PROVIDER: 'INCORRECT_IDENTITY_PROVIDER',
|
||||||
|
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
|
||||||
|
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
|
||||||
|
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
35
packages/lib/next-auth/get-server-component-session.ts
Normal file
35
packages/lib/next-auth/get-server-component-session.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { cache } from 'react';
|
||||||
|
|
||||||
|
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { NEXT_AUTH_OPTIONS } from './auth-options';
|
||||||
|
|
||||||
|
export const getServerComponentSession = cache(async () => {
|
||||||
|
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
||||||
|
|
||||||
|
if (!session || !session.user?.email) {
|
||||||
|
return { user: null, session: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
email: session.user.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user, session };
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getRequiredServerComponentSession = cache(async () => {
|
||||||
|
const { user, session } = await getServerComponentSession();
|
||||||
|
|
||||||
|
if (!user || !session) {
|
||||||
|
throw new Error('No session found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, session };
|
||||||
|
});
|
||||||
@ -1,4 +1,6 @@
|
|||||||
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
'use server';
|
||||||
|
|
||||||
|
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
||||||
|
|
||||||
@ -26,29 +28,3 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
|
|||||||
|
|
||||||
return { user, session };
|
return { user, session };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getServerComponentSession = async () => {
|
|
||||||
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
|
||||||
|
|
||||||
if (!session || !session.user?.email) {
|
|
||||||
return { user: null, session: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
|
||||||
where: {
|
|
||||||
email: session.user.email,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { user, session };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRequiredServerComponentSession = async () => {
|
|
||||||
const { user, session } = await getServerComponentSession();
|
|
||||||
|
|
||||||
if (!user || !session) {
|
|
||||||
throw new Error('No session found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { user, session };
|
|
||||||
};
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
"next-auth/"
|
"next-auth/"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
"clean": "rimraf node_modules"
|
"clean": "rimraf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -22,6 +24,8 @@
|
|||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@documenso/signing": "*",
|
"@documenso/signing": "*",
|
||||||
"@next-auth/prisma-adapter": "1.0.7",
|
"@next-auth/prisma-adapter": "1.0.7",
|
||||||
|
"@noble/ciphers": "0.4.0",
|
||||||
|
"@noble/hashes": "1.3.2",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@scure/base": "^1.1.3",
|
"@scure/base": "^1.1.3",
|
||||||
"@sindresorhus/slugify": "^2.2.1",
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
@ -31,6 +35,7 @@
|
|||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "14.0.0",
|
"next": "14.0.0",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.3",
|
||||||
|
"oslo": "^0.17.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"remeda": "^1.27.1",
|
"remeda": "^1.27.1",
|
||||||
|
|||||||
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { compare } from 'bcrypt';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { ErrorCode } from '../../next-auth/error-codes';
|
||||||
|
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||||
|
|
||||||
|
type DisableTwoFactorAuthenticationOptions = {
|
||||||
|
user: User;
|
||||||
|
backupCode: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disableTwoFactorAuthentication = async ({
|
||||||
|
backupCode,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
}: 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 });
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
twoFactorBackupCodes: null,
|
||||||
|
twoFactorSecret: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { getBackupCodes } from './get-backup-code';
|
||||||
|
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||||
|
|
||||||
|
type EnableTwoFactorAuthenticationOptions = {
|
||||||
|
user: User;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enableTwoFactorAuthentication = async ({
|
||||||
|
user,
|
||||||
|
code,
|
||||||
|
}: EnableTwoFactorAuthenticationOptions) => {
|
||||||
|
if (user.identityProvider !== 'DOCUMENSO') {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.twoFactorSecret) {
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
|
||||||
|
|
||||||
|
if (!isValidToken) {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||||
|
|
||||||
|
return { recoveryCodes };
|
||||||
|
};
|
||||||
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
|
import { symmetricDecrypt } from '../../universal/crypto';
|
||||||
|
|
||||||
|
interface GetBackupCodesOptions {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZBackupCodeSchema = z.array(z.string());
|
||||||
|
|
||||||
|
export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
|
||||||
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!user.twoFactorEnabled) {
|
||||||
|
throw new Error('User has not enabled 2FA');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.twoFactorBackupCodes) {
|
||||||
|
throw new Error('User has no backup codes');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = JSON.parse(secret);
|
||||||
|
|
||||||
|
const result = ZBackupCodeSchema.safeParse(data);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
|
|
||||||
|
type IsTwoFactorAuthenticationEnabledOptions = {
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTwoFactorAuthenticationEnabled = ({
|
||||||
|
user,
|
||||||
|
}: IsTwoFactorAuthenticationEnabledOptions) => {
|
||||||
|
return (
|
||||||
|
user.twoFactorEnabled &&
|
||||||
|
user.identityProvider === 'DOCUMENSO' &&
|
||||||
|
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
|
||||||
|
);
|
||||||
|
};
|
||||||
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { base32 } from '@scure/base';
|
||||||
|
import { compare } from 'bcrypt';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { createTOTPKeyURI } from 'oslo/otp';
|
||||||
|
|
||||||
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.identityProvider !== 'DOCUMENSO') {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCorrectPassword = await compare(password, user.password);
|
||||||
|
|
||||||
|
if (!isCorrectPassword) {
|
||||||
|
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = crypto.randomBytes(10);
|
||||||
|
|
||||||
|
const backupCodes = new Array(10)
|
||||||
|
.fill(null)
|
||||||
|
.map(() => crypto.randomBytes(5).toString('hex'))
|
||||||
|
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
|
||||||
|
|
||||||
|
const accountName = user.email;
|
||||||
|
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
|
||||||
|
const encodedSecret = base32.encode(secret);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
twoFactorBackupCodes: symmetricEncrypt({
|
||||||
|
data: JSON.stringify(backupCodes),
|
||||||
|
key: key,
|
||||||
|
}),
|
||||||
|
twoFactorSecret: symmetricEncrypt({
|
||||||
|
data: encodedSecret,
|
||||||
|
key: key,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
secret: encodedSecret,
|
||||||
|
uri,
|
||||||
|
};
|
||||||
|
};
|
||||||
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { ErrorCode } from '../../next-auth/error-codes';
|
||||||
|
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||||
|
import { verifyBackupCode } from './verify-backup-code';
|
||||||
|
|
||||||
|
type ValidateTwoFactorAuthenticationOptions = {
|
||||||
|
totpCode?: string;
|
||||||
|
backupCode?: string;
|
||||||
|
user: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateTwoFactorAuthentication = async ({
|
||||||
|
backupCode,
|
||||||
|
totpCode,
|
||||||
|
user,
|
||||||
|
}: ValidateTwoFactorAuthenticationOptions) => {
|
||||||
|
if (!user.twoFactorEnabled) {
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.twoFactorSecret) {
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totpCode) {
|
||||||
|
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupCode) {
|
||||||
|
return await verifyBackupCode({ user, backupCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
|
||||||
|
};
|
||||||
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { base32 } from '@scure/base';
|
||||||
|
import { TOTPController } from 'oslo/otp';
|
||||||
|
|
||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
|
import { symmetricDecrypt } from '../../universal/crypto';
|
||||||
|
|
||||||
|
const totp = new TOTPController();
|
||||||
|
|
||||||
|
type VerifyTwoFactorAuthenticationTokenOptions = {
|
||||||
|
user: User;
|
||||||
|
totpCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyTwoFactorAuthenticationToken = async ({
|
||||||
|
user,
|
||||||
|
totpCode,
|
||||||
|
}: VerifyTwoFactorAuthenticationTokenOptions) => {
|
||||||
|
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
if (!user.twoFactorSecret) {
|
||||||
|
throw new Error('user missing 2fa secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
|
||||||
|
|
||||||
|
return isValidToken;
|
||||||
|
};
|
||||||
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { getBackupCodes } from './get-backup-code';
|
||||||
|
|
||||||
|
type VerifyBackupCodeParams = {
|
||||||
|
user: User;
|
||||||
|
backupCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => {
|
||||||
|
const userBackupCodes = await getBackupCodes({ user });
|
||||||
|
|
||||||
|
if (!userBackupCodes) {
|
||||||
|
throw new Error('User has no backup codes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return userBackupCodes.includes(backupCode);
|
||||||
|
};
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { hashSync as bcryptHashSync } from 'bcrypt';
|
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
||||||
|
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
|
||||||
@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth';
|
|||||||
export const hashSync = (password: string) => {
|
export const hashSync = (password: string) => {
|
||||||
return bcryptHashSync(password, SALT_ROUNDS);
|
return bcryptHashSync(password, SALT_ROUNDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const compareSync = (password: string, hash: string) => {
|
||||||
|
return bcryptCompareSync(password, hash);
|
||||||
|
};
|
||||||
|
|||||||
56
packages/lib/server-only/auth/send-confirmation-email.ts
Normal file
56
packages/lib/server-only/auth/send-confirmation-email.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface SendConfirmationEmailProps {
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
VerificationToken: {
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [verificationToken] = user.VerificationToken;
|
||||||
|
|
||||||
|
if (!verificationToken?.token) {
|
||||||
|
throw new Error('Verification token not found for the user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||||
|
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||||
|
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||||
|
|
||||||
|
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||||
|
assetBaseUrl,
|
||||||
|
confirmationLink,
|
||||||
|
});
|
||||||
|
|
||||||
|
return mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: user.email,
|
||||||
|
name: user.name || '',
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: senderName,
|
||||||
|
address: senderAdress,
|
||||||
|
},
|
||||||
|
subject: 'Please confirm your email',
|
||||||
|
html: render(confirmationTemplate),
|
||||||
|
text: render(confirmationTemplate, { plainText: true }),
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -94,6 +94,7 @@ export const completeDocumentWithToken = async ({
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
status: DocumentStatus.COMPLETED,
|
status: DocumentStatus.COMPLETED,
|
||||||
|
completedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export interface DuplicateDocumentByIdOptions {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByIdOptions) => {
|
||||||
|
const document = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
userId: true,
|
||||||
|
documentData: {
|
||||||
|
select: {
|
||||||
|
data: true,
|
||||||
|
initialData: true,
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentMeta: {
|
||||||
|
select: {
|
||||||
|
message: true,
|
||||||
|
subject: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdDocument = await prisma.document.create({
|
||||||
|
data: {
|
||||||
|
title: document.title,
|
||||||
|
User: {
|
||||||
|
connect: {
|
||||||
|
id: document.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentData: {
|
||||||
|
create: {
|
||||||
|
...document.documentData,
|
||||||
|
data: document.documentData.initialData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentMeta: {
|
||||||
|
create: {
|
||||||
|
...document.documentMeta,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdDocument.id;
|
||||||
|
};
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { match } from 'ts-pattern';
|
import { DateTime } from 'luxon';
|
||||||
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { Document, Prisma, SigningStatus } from '@documenso/prisma/client';
|
import { Document, Prisma, SigningStatus } from '@documenso/prisma/client';
|
||||||
@ -16,6 +17,7 @@ export interface FindDocumentsOptions {
|
|||||||
column: keyof Omit<Document, 'document'>;
|
column: keyof Omit<Document, 'document'>;
|
||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
|
period?: '' | '7d' | '14d' | '30d';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const findDocuments = async ({
|
export const findDocuments = async ({
|
||||||
@ -25,6 +27,7 @@ export const findDocuments = async ({
|
|||||||
page = 1,
|
page = 1,
|
||||||
perPage = 10,
|
perPage = 10,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
period,
|
||||||
}: FindDocumentsOptions) => {
|
}: FindDocumentsOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@ -35,14 +38,16 @@ export const findDocuments = async ({
|
|||||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||||
|
|
||||||
const termFilters = !term
|
const termFilters = match(term)
|
||||||
? undefined
|
.with(P.string.minLength(1), () => {
|
||||||
: ({
|
return {
|
||||||
title: {
|
title: {
|
||||||
contains: term,
|
contains: term,
|
||||||
mode: 'insensitive',
|
mode: 'insensitive',
|
||||||
},
|
},
|
||||||
} as const);
|
} as const;
|
||||||
|
})
|
||||||
|
.otherwise(() => undefined);
|
||||||
|
|
||||||
const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||||
@ -113,12 +118,24 @@ export const findDocuments = async ({
|
|||||||
}))
|
}))
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
const [data, count] = await Promise.all([
|
const whereClause = {
|
||||||
prisma.document.findMany({
|
|
||||||
where: {
|
|
||||||
...termFilters,
|
...termFilters,
|
||||||
...filters,
|
...filters,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||||
|
|
||||||
|
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||||
|
|
||||||
|
whereClause.createdAt = {
|
||||||
|
gte: startOfPeriod.toJSDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, count] = await Promise.all([
|
||||||
|
prisma.document.findMany({
|
||||||
|
where: whereClause,
|
||||||
skip: Math.max(page - 1, 0) * perPage,
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
take: perPage,
|
take: perPage,
|
||||||
orderBy: {
|
orderBy: {
|
||||||
|
|||||||
99
packages/lib/server-only/document/resend-document.tsx
Normal file
99
packages/lib/server-only/document/resend-document.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { createElement } from 'react';
|
||||||
|
|
||||||
|
import { mailer } from '@documenso/email/mailer';
|
||||||
|
import { render } from '@documenso/email/render';
|
||||||
|
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||||
|
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||||
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type ResendDocumentOptions = {
|
||||||
|
documentId: number;
|
||||||
|
userId: number;
|
||||||
|
recipients: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => {
|
||||||
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const document = await prisma.document.findUnique({
|
||||||
|
where: {
|
||||||
|
id: documentId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
Recipient: {
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: recipients,
|
||||||
|
},
|
||||||
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
documentMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const customEmail = document?.documentMeta;
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Error('Document not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.Recipient.length === 0) {
|
||||||
|
throw new Error('Document has no recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.DRAFT) {
|
||||||
|
throw new Error('Can not send draft document');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.status === DocumentStatus.COMPLETED) {
|
||||||
|
throw new Error('Can not send completed document');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
document.Recipient.map(async (recipient) => {
|
||||||
|
const { email, name } = recipient;
|
||||||
|
|
||||||
|
const customEmailTemplate = {
|
||||||
|
'signer.name': name,
|
||||||
|
'signer.email': email,
|
||||||
|
'document.name': document.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
|
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||||
|
|
||||||
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
|
documentName: document.title,
|
||||||
|
inviterName: user.name || undefined,
|
||||||
|
inviterEmail: user.email,
|
||||||
|
assetBaseUrl,
|
||||||
|
signDocumentLink,
|
||||||
|
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||||
|
});
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
to: {
|
||||||
|
address: email,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
from: {
|
||||||
|
name: FROM_NAME,
|
||||||
|
address: FROM_ADDRESS,
|
||||||
|
},
|
||||||
|
subject: customEmail?.subject
|
||||||
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
|
: 'Please sign this document',
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
};
|
||||||
@ -105,7 +105,7 @@ export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): st
|
|||||||
const config = extractPostHogConfig();
|
const config = extractPostHogConfig();
|
||||||
|
|
||||||
const email = jwt?.email;
|
const email = jwt?.email;
|
||||||
const userId = jwt?.id.toString();
|
const userId = jwt?.id?.toString();
|
||||||
|
|
||||||
let fallbackDistinctId = nanoid();
|
let fallbackDistinctId = nanoid();
|
||||||
|
|
||||||
|
|||||||
41
packages/lib/server-only/user/generate-confirmation-token.ts
Normal file
41
packages/lib/server-only/user/generate-confirmation-token.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { ONE_HOUR } from '../../constants/time';
|
||||||
|
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||||
|
|
||||||
|
const IDENTIFIER = 'confirmation-email';
|
||||||
|
|
||||||
|
export const generateConfirmationToken = async ({ email }: { email: string }) => {
|
||||||
|
const token = crypto.randomBytes(20).toString('hex');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdToken = await prisma.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
identifier: IDENTIFIER,
|
||||||
|
token: token,
|
||||||
|
expires: new Date(Date.now() + ONE_HOUR),
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createdToken) {
|
||||||
|
throw new Error(`Failed to create the verification token`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendConfirmationEmail({ userId: user.id });
|
||||||
|
};
|
||||||
34
packages/lib/server-only/user/get-user-monthly-growth.ts
Normal file
34
packages/lib/server-only/user/get-user-monthly-growth.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetUserMonthlyGrowthResult = Array<{
|
||||||
|
month: string;
|
||||||
|
count: number;
|
||||||
|
cume_count: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type GetUserMonthlyGrowthQueryResult = Array<{
|
||||||
|
month: Date;
|
||||||
|
count: bigint;
|
||||||
|
cume_count: bigint;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const getUserMonthlyGrowth = async () => {
|
||||||
|
const result = await prisma.$queryRaw<GetUserMonthlyGrowthQueryResult>`
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('month', "createdAt") AS "month",
|
||||||
|
COUNT("id") as "count",
|
||||||
|
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "createdAt")) as "cume_count"
|
||||||
|
FROM "User"
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
};
|
||||||
41
packages/lib/server-only/user/send-confirmation-token.ts
Normal file
41
packages/lib/server-only/user/send-confirmation-token.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { ONE_HOUR } from '../../constants/time';
|
||||||
|
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||||
|
|
||||||
|
const IDENTIFIER = 'confirmation-email';
|
||||||
|
|
||||||
|
export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
||||||
|
const token = crypto.randomBytes(20).toString('hex');
|
||||||
|
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdToken = await prisma.verificationToken.create({
|
||||||
|
data: {
|
||||||
|
identifier: IDENTIFIER,
|
||||||
|
token: token,
|
||||||
|
expires: new Date(Date.now() + ONE_HOUR),
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createdToken) {
|
||||||
|
throw new Error(`Failed to create the verification token`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendConfirmationEmail({ userId: user.id });
|
||||||
|
};
|
||||||
70
packages/lib/server-only/user/verify-email.ts
Normal file
70
packages/lib/server-only/user/verify-email.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { sendConfirmationToken } from './send-confirmation-token';
|
||||||
|
|
||||||
|
export type VerifyEmailProps = {
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||||
|
const verificationToken = await prisma.verificationToken.findFirst({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!verificationToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the token is valid or expired
|
||||||
|
const valid = verificationToken.expires > new Date();
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
const mostRecentToken = await prisma.verificationToken.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: verificationToken.userId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there isn't a recent token or it's older than 1 hour, send a new token
|
||||||
|
if (
|
||||||
|
!mostRecentToken ||
|
||||||
|
DateTime.now().minus({ hours: 1 }).toJSDate() > mostRecentToken.createdAt
|
||||||
|
) {
|
||||||
|
await sendConfirmationToken({ email: verificationToken.user.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updatedUser, deletedToken] = await prisma.$transaction([
|
||||||
|
prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: verificationToken.userId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
emailVerified: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.verificationToken.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: verificationToken.userId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!updatedUser || !deletedToken) {
|
||||||
|
throw new Error('Something went wrong while verifying your email. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!updatedUser && !!deletedToken;
|
||||||
|
};
|
||||||
32
packages/lib/universal/crypto.ts
Normal file
32
packages/lib/universal/crypto.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
|
||||||
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils';
|
||||||
|
import { managedNonce } from '@noble/ciphers/webcrypto/utils';
|
||||||
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
|
|
||||||
|
export type SymmetricEncryptOptions = {
|
||||||
|
key: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => {
|
||||||
|
const keyAsBytes = sha256(key);
|
||||||
|
const dataAsBytes = utf8ToBytes(data);
|
||||||
|
|
||||||
|
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
|
||||||
|
|
||||||
|
return bytesToHex(chacha.encrypt(dataAsBytes));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SymmetricDecryptOptions = {
|
||||||
|
key: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
|
||||||
|
const keyAsBytes = sha256(key);
|
||||||
|
const dataAsBytes = hexToBytes(data);
|
||||||
|
|
||||||
|
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
|
||||||
|
|
||||||
|
return chacha.decrypt(dataAsBytes);
|
||||||
|
};
|
||||||
3
packages/lib/universal/unit-convertions.ts
Normal file
3
packages/lib/universal/unit-convertions.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function megabytesToBytes(megabytes: number) {
|
||||||
|
return megabytes * 1000000;
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import slugify from '@sindresorhus/slugify';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
|
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
|
||||||
import { getServerComponentSession } from '../../next-auth/get-server-session';
|
import { getServerComponentSession } from '../../next-auth/get-server-component-session';
|
||||||
import { alphaid } from '../id';
|
import { alphaid } from '../id';
|
||||||
|
|
||||||
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
||||||
|
|||||||
@ -23,6 +23,11 @@ export const getDatabaseUrl = () => {
|
|||||||
process.env.NEXT_PRIVATE_DIRECT_DATABASE_URL = process.env.POSTGRES_URL_NON_POOLING;
|
process.env.NEXT_PRIVATE_DIRECT_DATABASE_URL = process.env.POSTGRES_URL_NON_POOLING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we don't have a database URL, we can't normalize it.
|
||||||
|
if (!process.env.NEXT_PRIVATE_DATABASE_URL) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// We change the protocol from `postgres:` to `https:` so we can construct a easily
|
// We change the protocol from `postgres:` to `https:` so we can construct a easily
|
||||||
// mofifiable URL.
|
// mofifiable URL.
|
||||||
const url = new URL(process.env.NEXT_PRIVATE_DATABASE_URL.replace('postgres://', 'https://'));
|
const url = new URL(process.env.NEXT_PRIVATE_DATABASE_URL.replace('postgres://', 'https://'));
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VerificationToken" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
UPDATE "User"
|
||||||
|
SET "emailVerified" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "emailVerified" IS NULL;
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Document" ADD COLUMN "completedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
UPDATE "Document" SET "completedAt" = "updatedAt" WHERE "status" = 'COMPLETED';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user