diff --git a/.env.example b/.env.example index 7bd71c04b..45c26f6be 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,11 @@ NEXTAUTH_URL="http://localhost:3000" 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]] NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index f5fdb5166..08bb25daa 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -10,7 +10,7 @@ body: - type: textarea attributes: label: Issue Description - description: Please provide a clear and concise description of the problem. + description: Please provide a clear and concise description of the problem. - type: textarea attributes: label: Steps to Reproduce @@ -44,4 +44,5 @@ body: - label: I have provided steps to reproduce the issue. - label: I have included relevant environment information. - label: I have included any relevant screenshots. - - label: I understand that this is a voluntary contribution and that there is no guarantee of resolution. \ No newline at end of file + - 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 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..2a8e1455b --- /dev/null +++ b/.prettierignore @@ -0,0 +1,16 @@ +node_modules +.next +public +**/**/node_modules +**/**/.next +**/**/public + +*.lock +*.log +*.test.ts + +.gitignore +.npmignore +.prettierignore +.DS_Store +.eslintignore diff --git a/README.md b/README.md index 8a8bc88eb..fac3e319c 100644 --- a/README.md +++ b/README.md @@ -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) + Documenso Logo

@@ -217,7 +219,7 @@ Then, inside the `documenso` folder, copy the example env file: cp .env.example .env ``` -The following environement variables must be set: +The following environment variables must be set: * `NEXTAUTH_URL` * `NEXTAUTH_SECRET` diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 306e94d89..cc49f85a4 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start -p 3001", "lint": "next lint", + "lint:fix": "next lint --fix", "clean": "rimraf .next && rimraf node_modules", "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" }, @@ -20,6 +21,7 @@ "contentlayer": "^0.3.4", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", + "luxon": "^3.4.0", "micro": "^10.0.1", "next": "14.0.0", "next-auth": "4.24.3", diff --git a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx index 1af95116a..d13c1a947 100644 --- a/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx +++ b/apps/marketing/src/app/(marketing)/blog/[post]/opengraph-image.tsx @@ -41,7 +41,7 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra return new ImageResponse( ( -

+
{/* @ts-expect-error Lack of typing from ImageResponse */} og-background diff --git a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx index aa3929833..940adb8fc 100644 --- a/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx +++ b/apps/marketing/src/app/(marketing)/open/bar-metrics.tsx @@ -40,9 +40,9 @@ export const BarMetric = {extraInfo}
-
+
- + [Number(value), label]} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} /> - {' '} +
diff --git a/apps/marketing/src/app/(marketing)/open/funding-raised.tsx b/apps/marketing/src/app/(marketing)/open/funding-raised.tsx index 92ba9e097..fbfce47da 100644 --- a/apps/marketing/src/app/(marketing)/open/funding-raised.tsx +++ b/apps/marketing/src/app/(marketing)/open/funding-raised.tsx @@ -21,7 +21,7 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps)

Total Funding Raised

-
+
@@ -51,7 +51,13 @@ export const FundingRaised = ({ className, data, ...props }: FundingRaisedProps) ]} cursor={{ fill: 'hsl(var(--primary) / 10%)' }} /> - +
diff --git a/apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx b/apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx new file mode 100644 index 000000000..5aae8eeb5 --- /dev/null +++ b/apps/marketing/src/app/(marketing)/open/monthly-new-users-chart.tsx @@ -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 ( +
+
+

Monthly New Users

+
+ +
+ + + + + + [Number(value).toLocaleString('en-US'), 'New Users']} + cursor={{ fill: 'hsl(var(--primary) / 10%)' }} + /> + + + + +
+
+ ); +}; diff --git a/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx b/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx new file mode 100644 index 000000000..3c3f4476a --- /dev/null +++ b/apps/marketing/src/app/(marketing)/open/monthly-total-users-chart.tsx @@ -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 ( +
+
+

Monthly Total Users

+
+ +
+ + + + + + [Number(value).toLocaleString('en-US'), 'Total Users']} + cursor={{ fill: 'hsl(var(--primary) / 10%)' }} + /> + + + + +
+
+ ); +}; diff --git a/apps/marketing/src/app/(marketing)/open/page.tsx b/apps/marketing/src/app/(marketing)/open/page.tsx index debdf92b9..e237919bc 100644 --- a/apps/marketing/src/app/(marketing)/open/page.tsx +++ b/apps/marketing/src/app/(marketing)/open/page.tsx @@ -1,5 +1,7 @@ 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 { MetricCard } from '~/app/(marketing)/open/metric-card'; 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 { CapTable } from './cap-table'; 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 { OpenPageTooltip } from './tooltip'; export const revalidate = 3600; +export const dynamic = 'force-dynamic'; + +const GITHUB_HEADERS: Record = { + 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({ stargazers_count: z.number(), forks_count: z.number(), @@ -22,6 +36,10 @@ const ZMergedPullRequestsResponse = z.object({ total_count: z.number(), }); +const ZOpenIssuesResponse = z.object({ + total_count: z.number(), +}); + const ZStargazersLiveResponse = z.record( z.object({ stars: z.number(), @@ -42,45 +60,78 @@ const ZEarlyAdoptersResponse = z.record( export type StargazersType = z.infer; export type EarlyAdoptersType = z.infer; -export default async function OpenPage() { - const { - forks_count: forksCount, - open_issues: openIssues, - stargazers_count: stargazersCount, - } = await fetch('https://api.github.com/repos/documenso/documenso', { +const fetchGithubStats = async () => { + return await fetch('https://api.github.com/repos/documenso/documenso', { headers: { - accept: 'application/vnd.github.v3+json', + ...GITHUB_HEADERS, }, }) .then(async (res) => res.json()) .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', { headers: { - accept: 'application/vnd.github.v3+json', + ...GITHUB_HEADERS, }, }, ) .then(async (res) => res.json()) .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: { accept: 'application/json', }, }) .then(async (res) => res.json()) .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: { accept: 'application/json', }, }) .then(async (res) => res.json()) .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 (
@@ -122,7 +173,7 @@ export default async function OpenPage() { - + @@ -172,6 +223,9 @@ export default async function OpenPage() { className="col-span-12 lg:col-span-6" /> + + +

Where's the rest?

diff --git a/apps/marketing/src/app/(marketing)/open/tooltip.tsx b/apps/marketing/src/app/(marketing)/open/tooltip.tsx index e6bf48a94..0ae92d535 100644 --- a/apps/marketing/src/app/(marketing)/open/tooltip.tsx +++ b/apps/marketing/src/app/(marketing)/open/tooltip.tsx @@ -23,8 +23,8 @@ export function OpenPageTooltip() { diff --git a/apps/marketing/src/components/(marketing)/footer.tsx b/apps/marketing/src/components/(marketing)/footer.tsx index 9c6d0d44d..8e0fccd4b 100644 --- a/apps/marketing/src/components/(marketing)/footer.tsx +++ b/apps/marketing/src/components/(marketing)/footer.tsx @@ -5,13 +5,12 @@ import { HTMLAttributes } from 'react'; import Image from 'next/image'; import Link from 'next/link'; -import { Moon, Sun } from 'lucide-react'; -import { useTheme } from 'next-themes'; import { FaXTwitter } from 'react-icons/fa6'; import { LiaDiscord } from 'react-icons/lia'; import { LuGithub } from 'react-icons/lu'; import { cn } from '@documenso/ui/lib/utils'; +import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher'; export type FooterProps = HTMLAttributes; @@ -34,8 +33,6 @@ const FOOTER_LINKS = [ ]; export const Footer = ({ className, ...props }: FooterProps) => { - const { setTheme } = useTheme(); - return (
@@ -77,21 +74,13 @@ export const Footer = ({ className, ...props }: FooterProps) => { ))}
-
+

© {new Date().getFullYear()} Documenso, Inc. All rights reserved.

-
- - - +
+
diff --git a/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts b/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts index 1e779fcfc..29321f31e 100644 --- a/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts +++ b/apps/marketing/src/components/(marketing)/single-player-mode/create-single-player-document.action.ts @@ -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 { alphaid } from '@documenso/lib/universal/id'; import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; import { prisma } from '@documenso/prisma'; import { DocumentDataType, @@ -24,6 +25,7 @@ import { SendStatus, SigningStatus, } from '@documenso/prisma/client'; +import { signPdf } from '@documenso/signing'; const ZCreateSinglePlayerDocumentSchema = 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) => { - const documentToken = alphaid(); + const token = alphaid(); // Fetch service user who will be the owner of the document. 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 tx.documentData.create({ - data: { - type: DocumentDataType.BYTES_64, - data: documentDataBytes, - initialData: documentDataBytes, - }, + const { id: documentDataId } = await putFile({ + name: `${documentName}.pdf`, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(signedPdfBuffer), }); // Create document. @@ -137,7 +137,7 @@ export const createSinglePlayerDocument = async ( documentId: document.id, name: signer.name, email: signer.email, - token: documentToken, + token, signedAt: createdAt, readStatus: ReadStatus.OPENED, signingStatus: SigningStatus.SIGNED, @@ -169,7 +169,7 @@ export const createSinglePlayerDocument = async ( }), ); - return documentToken; + return { document, token }; }, { maxWait: 5000, @@ -195,10 +195,10 @@ export const createSinglePlayerDocument = async ( subject: 'Document signed', html: render(template), text: render(template, { plainText: true }), - attachments: [{ content: Buffer.from(pdfBytes), filename: documentName }], + attachments: [{ content: signedPdfBuffer, filename: documentName }], }); - return documentToken; + return token; }; /** diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 2026cce50..8ca3bb666 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -12,6 +12,7 @@ ENV_FILES.forEach((file) => { /** @type {import('next').NextConfig} */ const config = { + output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined, experimental: { serverActionsBodySizeLimit: '50mb', }, diff --git a/apps/web/package.json b/apps/web/package.json index 90da68c4d..8233a6c94 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "lint:fix": "next lint --fix", "clean": "rimraf .next && rimraf node_modules", "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" }, @@ -36,11 +37,13 @@ "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-hook-form": "^7.43.9", + "react-hotkeys-hook": "^4.4.1", "react-icons": "^4.11.0", "react-rnd": "^10.4.1", "sharp": "0.32.5", "ts-pattern": "^5.0.5", "typescript": "5.2.2", + "uqr": "^0.1.2", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/web/src/app/(dashboard)/admin/layout.tsx b/apps/web/src/app/(dashboard)/admin/layout.tsx index 60066fc10..12330679d 100644 --- a/apps/web/src/app/(dashboard)/admin/layout.tsx +++ b/apps/web/src/app/(dashboard)/admin/layout.tsx @@ -2,7 +2,7 @@ import React from 'react'; 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 { AdminNav } from './nav'; diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 90ed0de4d..3baf5d63b 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import type { z } from 'zod'; import { trpc } from '@documenso/trpc/react'; import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; diff --git a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx index 3493bcf30..08d5f61d3 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/page.tsx @@ -3,7 +3,7 @@ import { redirect } from 'next/navigation'; 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 { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; diff --git a/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx new file mode 100644 index 000000000..056d6f3b0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/_action-items/resend-document.tsx @@ -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; + +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({ + 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 ( + <> + + + e.preventDefault()}> + + Resend + + + + + + +

Who do you want to remind?

+
+
+ +
+ + ( + <> + {recipients.map((recipient) => ( + + + + {recipient.email} + + + + + checked + ? onChange([...value, recipient.id]) + : onChange(value.filter((v) => v !== recipient.id)) + } + /> + + + ))} + + )} + /> + + + + +
+ + + + + +
+
+
+
+ + ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 1a58d19cd..72ac08915 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -6,14 +6,9 @@ import { Edit, Pencil, Share } from 'lucide-react'; import { useSession } from 'next-auth/react'; 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 { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { Button } from '@documenso/ui/primitives/button'; -import { useToast } from '@documenso/ui/primitives/use-toast'; export type DataTableActionButtonProps = { row: Document & { @@ -25,13 +20,6 @@ export type DataTableActionButtonProps = { export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { 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) { return null; } @@ -70,18 +58,15 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { )) .otherwise(() => ( - + ( + + )} + /> )); }; diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index bec966f9e..d1a7dcb5f 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -8,7 +8,6 @@ import { Copy, Download, Edit, - History, Loader, MoreHorizontal, Pencil, @@ -18,15 +17,12 @@ import { } from 'lucide-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 { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; -import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import type { Document, Recipient, User } from '@documenso/prisma/client'; +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 { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DropdownMenu, DropdownMenuContent, @@ -34,9 +30,10 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } 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 { DuplicateDocumentDialog } from './duplicate-document-dialog'; export type DataTableActionDropdownProps = { row: Document & { @@ -48,14 +45,8 @@ export type DataTableActionDropdownProps = { export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { 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 [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false); if (!session) { return null; @@ -106,6 +97,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = window.URL.revokeObjectURL(link.href); }; + const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); return ( @@ -134,7 +126,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Download - + setDuplicateDialogOpen(true)}> Duplicate @@ -151,27 +143,20 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Share - - - Resend - + - - createAndCopyShareLink({ - token: recipient?.token, - documentId: row.id, - }) - } - > - {isCopyingShareLink ? ( - - ) : ( - + ( + e.preventDefault()}> +
+ {loading ? : } + Share +
+
)} - Share -
+ /> {isDocumentDeletable && ( @@ -181,6 +166,13 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = onOpenChange={setDeleteDialogOpen} /> )} + {isDuplicateDialogOpen && ( + + )}
); }; diff --git a/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx new file mode 100644 index 000000000..6400b8108 --- /dev/null +++ b/apps/web/src/app/(dashboard)/documents/duplicate-document-dialog.tsx @@ -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 ( + !isLoading && onOpenChange(value)}> + + + Duplicate + + {!documentData || isLoading ? ( +
+

+ Loading Document... +

+
+ ) : ( +
+ +
+ )} + + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index d8a5a5bd8..be0779042 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -1,6 +1,6 @@ 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 { getStats } from '@documenso/lib/server-only/document/get-stats'; 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 { 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 { DocumentsDataTable } from './data-table'; @@ -32,7 +33,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage }); 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 perPage = Number(searchParams.perPage) || 20; @@ -45,6 +46,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage }, page, perPage, + period, }); const getTabHref = (value: typeof status) => { diff --git a/apps/web/src/app/(dashboard)/documents/upload-document.tsx b/apps/web/src/app/(dashboard)/documents/upload-document.tsx index 644c9017a..9963d072a 100644 --- a/apps/web/src/app/(dashboard)/documents/upload-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/upload-document.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { Loader } from 'lucide-react'; +import { useSession } from 'next-auth/react'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; @@ -22,6 +23,7 @@ export type UploadDocumentProps = { export const UploadDocument = ({ className }: UploadDocumentProps) => { const router = useRouter(); + const { data: session } = useSession(); const { toast } = useToast(); @@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index efd3aa2ea..3bd79c8a7 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -6,9 +6,11 @@ import { getServerSession } from 'next-auth'; import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server'; 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 { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner'; import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus'; import { NextAuthProvider } from '~/providers/next-auth'; @@ -30,6 +32,8 @@ export default async function AuthenticatedDashboardLayout({ return ( + {!user.emailVerified && } +
{children}
diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts index cef36ee3f..ee5dbf175 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -5,8 +5,9 @@ import { getStripeCustomerById, } from '@documenso/ee/server-only/stripe/get-customer'; import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; -import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +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'; export const createBillingPortal = async () => { diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts index 2f07c37dd..0552c55ec 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts @@ -7,8 +7,8 @@ import { getStripeCustomerById, } from '@documenso/ee/server-only/stripe/get-customer'; import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; -import { Stripe } from '@documenso/lib/server-only/stripe'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { Stripe } from '@documenso/lib/server-only/stripe'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; export type CreateCheckoutOptions = { diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 9f7e44e25..61dff3216 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -4,9 +4,9 @@ import { match } from 'ts-pattern'; 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 { 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 { 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 { LocaleDate } from '~/components/formatter/locale-date'; @@ -41,7 +41,7 @@ export default async function BillingSettingsPage() { return (
-

Billing

+

Billing

{isMissingOrInactiveOrFreePlan && ( diff --git a/apps/web/src/app/(dashboard)/settings/password/page.tsx b/apps/web/src/app/(dashboard)/settings/password/page.tsx index 90fcbe25d..dd344a1d1 100644 --- a/apps/web/src/app/(dashboard)/settings/password/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/password/page.tsx @@ -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 async function PasswordSettingsPage() { - const { user } = await getRequiredServerComponentSession(); - - return ( -
-

Password

- -

Here you can update your password.

- -
- - -
- ); +export default function PasswordSettingsPage() { + redirect('/settings/security'); } diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index 716f3c39c..cb64fb9cd 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -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'; @@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() { return (
-

Profile

+

Profile

Here you can edit your personal details.

diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx new file mode 100644 index 000000000..9e99b73e8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -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 ( +
+

Security

+ +

+ Here you can manage your password and security settings. +

+ +
+ + + +
+ +

Two Factor Authentication

+ +

+ Add and manage your two factor security settings to add an extra layer of security to your + account! +

+ +
+
Two-factor methods
+ + +
+ + {user.twoFactorEnabled && ( +
+
Recovery methods
+ + +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(share)/share/[slug]/opengraph/route.tsx b/apps/web/src/app/(share)/share/[slug]/opengraph/route.tsx index 7cd7059f7..dfa715fb0 100644 --- a/apps/web/src/app/(share)/share/[slug]/opengraph/route.tsx +++ b/apps/web/src/app/(share)/share/[slug]/opengraph/route.tsx @@ -56,7 +56,7 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen return new ImageResponse( ( -
+
{/* @ts-expect-error Lack of typing from ImageResponse */} og-share-frame @@ -149,6 +149,10 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen weight: 600, }, ], + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + }, }, ); } diff --git a/apps/web/src/app/(signing)/sign/[token]/form.tsx b/apps/web/src/app/(signing)/sign/[token]/form.tsx index 1e5b916e3..ecdca8f93 100644 --- a/apps/web/src/app/(signing)/sign/[token]/form.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/form.tsx @@ -28,6 +28,7 @@ import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { DATE_FORMATS } from '~/helpers/constants'; import { useRequiredSigningContext } from './provider'; +import { SignDialog } from './sign-dialog'; export type SigningFormProps = { document: Document; @@ -57,6 +58,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = const onFormSubmit = async () => { setValidateUninsertedFields(true); + const isFieldsValid = validateFieldsInserted(fields); if (!isFieldsValid) { @@ -168,9 +170,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) = Cancel - +
diff --git a/apps/web/src/app/(signing)/sign/[token]/layout.tsx b/apps/web/src/app/(signing)/sign/[token]/layout.tsx index a25c16c0d..cfec41cdf 100644 --- a/apps/web/src/app/(signing)/sign/[token]/layout.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/layout.tsx @@ -1,6 +1,6 @@ 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 { NextAuthProvider } from '~/providers/next-auth'; diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index a1ad0b170..67e679412 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -3,7 +3,7 @@ import { notFound, redirect } from 'next/navigation'; import { match } from 'ts-pattern'; 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 { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx new file mode 100644 index 000000000..0ce750a39 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -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; +}; + +export const SignDialog = ({ + isSubmitting, + document, + fields, + onSignatureComplete, +}: SignDialogProps) => { + const [showDialog, setShowDialog] = useState(false); + + const isComplete = fields.every((field) => field.inserted); + + return ( + + + + + +
+
Sign Document
+
+ You are about to finish signing "{document.title}". Are you sure? +
+
+ + +
+ + + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx index 4f0617f7c..20ecddf4d 100644 --- a/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx +++ b/apps/web/src/app/(unauthenticated)/forgot-password/page.tsx @@ -5,7 +5,7 @@ import { ForgotPasswordForm } from '~/components/forms/forgot-password'; export default function ForgotPasswordPage() { return (
-

Forgotten your password?

+

Forgot your password?

No worries, it happens! Enter your email and we'll email you a special link to reset your diff --git a/apps/web/src/app/(unauthenticated)/signin/page.tsx b/apps/web/src/app/(unauthenticated)/signin/page.tsx index 868b0471d..a4890d849 100644 --- a/apps/web/src/app/(unauthenticated)/signin/page.tsx +++ b/apps/web/src/app/(unauthenticated)/signin/page.tsx @@ -25,7 +25,7 @@ export default function SignInPage() { href="/forgot-password" className="text-muted-foreground text-sm duration-200 hover:opacity-70" > - Forgotten your password? + Forgot your password?

diff --git a/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx new file mode 100644 index 000000000..f671fb101 --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/verify-email/[token]/page.tsx @@ -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 ( +
+
+ +
+ +

No token provided

+

+ It seems that there is no token provided. Please check your email and try again. +

+
+ ); + } + + const verified = await verifyEmail({ token }); + + if (verified === null) { + return ( +
+
+ +
+ +
+

Something went wrong

+ +

+ We were unable to verify your email. If your email is not verified already, please try + again. +

+ + +
+
+ ); + } + + if (!verified) { + return ( +
+
+ +
+ +
+

Your token has expired!

+ +

+ It seems that the provided token has expired. We've just sent you another token, please + check your email and try again. +

+ + +
+
+ ); + } + + return ( +
+
+ +
+ +
+

Email Confirmed!

+ +

+ Your email has been successfully confirmed! You can now use all features of Documenso. +

+ + +
+
+ ); +} diff --git a/apps/web/src/app/(unauthenticated)/verify-email/page.tsx b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx new file mode 100644 index 000000000..04202d19b --- /dev/null +++ b/apps/web/src/app/(unauthenticated)/verify-email/page.tsx @@ -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 ( +
+
+ +
+ +
+

Uh oh! Looks like you're missing a token

+ +

+ It seems that there is no token provided, if you are trying to verify your email please + follow the link in your email. +

+ + +
+
+ ); +} diff --git a/apps/web/src/app/not-found.tsx b/apps/web/src/app/not-found.tsx index f580655af..76017c121 100644 --- a/apps/web/src/app/not-found.tsx +++ b/apps/web/src/app/not-found.tsx @@ -1,6 +1,6 @@ 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 NotFoundPartial from '~/components/partials/not-found'; diff --git a/apps/web/src/components/(dashboard)/common/command-menu.tsx b/apps/web/src/components/(dashboard)/common/command-menu.tsx new file mode 100644 index 000000000..cc597cfeb --- /dev/null +++ b/apps/web/src/components/(dashboard)/common/command-menu.tsx @@ -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([]); + 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 ( + + + + No results found. + {!currentPage && ( + <> + + + + + + + + setPages([...pages, 'theme'])}>Change theme + + + )} + {currentPage === 'theme' && } + + + ); +} + +const Commands = ({ + push, + pages, +}: { + push: (_path: string) => void; + pages: { label: string; path: string; shortcut?: string }[]; +}) => { + return pages.map((page) => ( + push(page.path)}> + {page.label} + {page.shortcut && {page.shortcut}} + + )); +}; + +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) => ( + setTheme(theme.theme)}> + + {theme.label} + + )); +}; diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index d699dea4b..99761a0d3 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { CreditCard, - Key, + Lock, LogOut, User as LucideUser, Monitor, @@ -87,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - - - Password + + + Security diff --git a/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx new file mode 100644 index 000000000..24e47c186 --- /dev/null +++ b/apps/web/src/components/(dashboard)/layout/verify-email-banner.tsx @@ -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 ( + <> +
+
+
+ + Verify your email address to unlock all features. +
+ +
+ +
+
+
+ + + + Verify your email address + + + We've sent a confirmation email to {email}. Please check your inbox and + click the link in the email to verify your account. + + +
+ +
+
+
+ + ); +}; diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index 901c6a5ae..f4b2aae5e 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react'; import Link from 'next/link'; 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 { cn } from '@documenso/ui/lib/utils'; @@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - + diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx index ffe2b0d80..28ffc960f 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx @@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react'; import Link from 'next/link'; 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 { cn } from '@documenso/ui/lib/utils'; @@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => { - + diff --git a/apps/web/src/components/forms/2fa/authenticator-app.tsx b/apps/web/src/components/forms/2fa/authenticator-app.tsx new file mode 100644 index 000000000..1d164bd22 --- /dev/null +++ b/apps/web/src/components/forms/2fa/authenticator-app.tsx @@ -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 ( + <> +
+
+

Authenticator app

+ +

+ Create one-time passwords that serve as a secondary authentication method for confirming + your identity when requested during the sign-in process. +

+
+ +
+ {isTwoFactorEnabled ? ( + + ) : ( + + )} +
+
+ + !open && setModalState(null)} + /> + + !open && setModalState(null)} + /> + + ); +}; diff --git a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx new file mode 100644 index 000000000..eac574181 --- /dev/null +++ b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx @@ -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({ + 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 ( + + + + Disable Authenticator App + + + 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. + + + +
+ + ( + + Password + + + + + + )} + /> + + ( + + Backup Code + + + + + + )} + /> + +
+ + + +
+ + +
+
+ ); +}; diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx new file mode 100644 index 000000000..8bf835ef5 --- /dev/null +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -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; + +export const ZEnableTwoFactorAuthenticationForm = z.object({ + token: z.string(), +}); + +export type TEnableTwoFactorAuthenticationForm = z.infer; + +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({ + defaultValues: { + password: '', + }, + resolver: zodResolver(ZSetupTwoFactorAuthenticationForm), + }); + + const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } = + setupTwoFactorAuthenticationForm.formState; + + const enableTwoFactorAuthenticationForm = useForm({ + 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 ( + + + + Enable Authenticator App + + {step === 'setup' && ( + + To enable two-factor authentication, please enter your password below. + + )} + + {step === 'view' && ( + + Your recovery codes are listed below. Please store them in a safe place. + + )} + + + {match(step) + .with('setup', () => { + return ( +
+ + ( + + Password + + + + + + )} + /> + +
+ + + +
+ + + ); + }) + .with('enable', () => ( +
+ +

+ To enable two-factor authentication, scan the following QR code using your + authenticator app. +

+ +
+ +

+ If your authenticator app does not support QR codes, you can use the following + code instead: +

+ +

+ {setupTwoFactorAuthenticationData?.secret} +

+ +

+ Once you have scanned the QR code or entered the code manually, enter the code + provided by your authenticator app below. +

+ + ( + + Token + + + + + + )} + /> + +
+ + + +
+ + + )) + .with('view', () => ( +
+ {enableTwoFactorAuthenticationData?.recoveryCodes && ( + + )} + +
+ +
+
+ )) + .exhaustive()} + +
+ ); +}; diff --git a/apps/web/src/components/forms/2fa/recovery-code-list.tsx b/apps/web/src/components/forms/2fa/recovery-code-list.tsx new file mode 100644 index 000000000..d2efb0b4b --- /dev/null +++ b/apps/web/src/components/forms/2fa/recovery-code-list.tsx @@ -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 ( +
+ {recoveryCodes.map((code) => ( +
+ {code} + +
+ +
+
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/forms/2fa/recovery-codes.tsx b/apps/web/src/components/forms/2fa/recovery-codes.tsx new file mode 100644 index 000000000..7e8950227 --- /dev/null +++ b/apps/web/src/components/forms/2fa/recovery-codes.tsx @@ -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 ( + <> +
+
+

Recovery Codes

+ +

+ Recovery codes are used to access your account in the event that you lose access to your + authenticator app. +

+
+ +
+ +
+
+ + + + ); +}; diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx new file mode 100644 index 000000000..6275f16d6 --- /dev/null +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -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; + +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({ + 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 ( + + + + View Recovery Codes + + {step === 'authenticate' && ( + + To view your recovery codes, please enter your password below. + + )} + + {step === 'view' && ( + + Your recovery codes are listed below. Please store them in a safe place. + + )} + + + {match(step) + .with('authenticate', () => { + return ( +
+ + ( + + Password + + + + + + )} + /> + +
+ + + +
+ + + ); + }) + .with('view', () => ( +
+ {viewRecoveryCodesData?.recoveryCodes && ( + + )} + +
+ +
+
+ )) + .exhaustive()} +
+
+ ); +}; diff --git a/apps/web/src/components/forms/edit-document/add-fields.action.ts b/apps/web/src/components/forms/edit-document/add-fields.action.ts index c07758b9f..edc5e7e39 100644 --- a/apps/web/src/components/forms/edit-document/add-fields.action.ts +++ b/apps/web/src/components/forms/edit-document/add-fields.action.ts @@ -1,8 +1,8 @@ '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 { 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 & { documentId: number; diff --git a/apps/web/src/components/forms/edit-document/add-signers.action.ts b/apps/web/src/components/forms/edit-document/add-signers.action.ts index 05151498a..c36d51c41 100644 --- a/apps/web/src/components/forms/edit-document/add-signers.action.ts +++ b/apps/web/src/components/forms/edit-document/add-signers.action.ts @@ -1,8 +1,8 @@ '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 { 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 & { documentId: number; diff --git a/apps/web/src/components/forms/edit-document/add-subject.action.ts b/apps/web/src/components/forms/edit-document/add-subject.action.ts index 8fe37fecc..56d6f694d 100644 --- a/apps/web/src/components/forms/edit-document/add-subject.action.ts +++ b/apps/web/src/components/forms/edit-document/add-subject.action.ts @@ -1,9 +1,9 @@ '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 { 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 & { documentId: number; diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 43801038d..0d7dd723f 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff } from 'lucide-react'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; 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 { cn } from '@documenso/ui/lib/utils'; 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 { Input } from '@documenso/ui/primitives/input'; +import { Input, PasswordInput } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; -const ERROR_MESSAGES = { +const ERROR_MESSAGES: Partial> = { [ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect', [ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect', [ErrorCode.USER_MISSING_PASSWORD]: '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'; export const ZSignInFormSchema = z.object({ email: z.string().email().min(1), password: z.string().min(6).max(72), + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), }); export type TSignInFormSchema = z.infer; @@ -39,33 +45,84 @@ export type SignInFormProps = { export const SignInForm = ({ className }: SignInFormProps) => { const { toast } = useToast(); - const [showPassword, setShowPassword] = useState(false); + const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = + useState(false); + + const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< + 'totp' | 'backup' + >('totp'); const { register, handleSubmit, + setValue, formState: { errors, isSubmitting }, } = useForm({ values: { email: '', password: '', + totpCode: '', + backupCode: '', }, 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 { - const result = await signIn('credentials', { + const credentials: Record = { email, password, + }; + + if (totpCode) { + credentials.totpCode = totpCode; + } + + if (backupCode) { + credentials.backupCode = backupCode; + } + + const result = await signIn('credentials', { + ...credentials, + callbackUrl: LOGIN_REDIRECT_PATH, redirect: false, }); if (result?.error && isErrorCode(result.error)) { + if (result.error === TwoFactorEnabledErrorCode) { + setIsTwoFactorAuthenticationDialogOpen(true); + + return; + } + + const errorMessage = ERROR_MESSAGES[result.error]; + toast({ variant: 'destructive', - description: ERROR_MESSAGES[result.error], + title: 'Unable to sign in', + description: errorMessage ?? 'An unknown error occurred', }); return; @@ -118,31 +175,14 @@ export const SignInForm = ({ className }: SignInFormProps) => { Password -
- - - -
+
@@ -173,6 +213,67 @@ export const SignInForm = ({ className }: SignInFormProps) => { Google + + + + + Two-Factor Authentication + + +
+ {twoFactorAuthenticationMethod === 'totp' && ( +
+ + + + + +
+ )} + + {twoFactorAuthenticationMethod === 'backup' && ( +
+ + + + + +
+ )} + +
+ + + +
+
+
+
); }; diff --git a/docker/Dockerfile b/docker/Dockerfile index a50726eff..ecdd3b91b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,53 +1,86 @@ +########################### +# BASE CONTAINER # +########################### FROM node:18-alpine AS base -# Install dependencies only when needed -FROM base AS production_deps -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 +########################### +# BUILDER CONTAINER # +########################### FROM base AS builder -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 +RUN apk add --no-cache jq +WORKDIR /app -# Copy our current monorepo 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 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 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 + WORKDIR /app -ENV NODE_ENV production -ENV NEXT_TELEMETRY_DISABLED 1 - +# Don't run production as root RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs +USER nextjs -COPY --from=production_deps --chown=nextjs:nodejs /app/node_modules ./node_modules -COPY --from=production_deps --chown=nextjs:nodejs /app/package-lock.json ./package-lock.json +COPY --from=installer /app/apps/web/next.config.js . +COPY --from=installer /app/apps/web/package.json . -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./package.json -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./.next +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +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 - -ENV PORT 3000 - -CMD ["npm", "run", "start"] +CMD node apps/web/server.js diff --git a/docker/compose-test.yml b/docker/compose-test.yml new file mode 100644 index 000000000..e401aaf8f --- /dev/null +++ b/docker/compose-test.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 47b528347..f4baf14bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "packages/*" ], "dependencies": { + "react-hotkeys-hook": "^4.4.1", "recharts": "^2.7.2" }, "devDependencies": { @@ -43,6 +44,7 @@ "contentlayer": "^0.3.4", "framer-motion": "^10.12.8", "lucide-react": "^0.279.0", + "luxon": "^3.4.0", "micro": "^10.0.1", "next": "14.0.0", "next-auth": "4.24.3", @@ -101,11 +103,13 @@ "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-hook-form": "^7.43.9", + "react-hotkeys-hook": "^4.4.1", "react-icons": "^4.11.0", "react-rnd": "^10.4.1", "sharp": "0.32.5", "ts-pattern": "^5.0.5", "typescript": "5.2.2", + "uqr": "^0.1.2", "zod": "^3.22.4" }, "devDependencies": { @@ -1703,11 +1707,11 @@ "link": true }, "node_modules/@documenso/nodemailer-resend": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@documenso/nodemailer-resend/-/nodemailer-resend-1.0.0.tgz", - "integrity": "sha512-rG+jBbBEsVJUBU6v/2hb+OQD1m3Lhn49TOzQjln73zzL1B/sZsHhYOKpNPlTX0/FafCP7P9fKerndEeIKn54Vw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@documenso/nodemailer-resend/-/nodemailer-resend-2.0.0.tgz", + "integrity": "sha512-fbcRrJ9cWJ7/GQIXe8j5HKPpu5TB29jEvpG3H2OZWYlTF3kWoVPixd9wQ9uZNyilyYxqSYxJ4r4WVnAmxNseYA==", "dependencies": { - "resend": "^1.1.0" + "resend": "^2.0.0" }, "peerDependencies": { "nodemailer": "^6.9.3" @@ -2851,6 +2855,465 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.4.0.tgz", + "integrity": "sha512-xaUaUUDWbHIFSxaQ/pIe+33VG2mfJp6N/KxKLmZr5biWdNznCAmfu24QRhX10BbVAuqOahAoyp0S4M9md6GPDw==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@node-rs/argon2": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-1.5.2.tgz", + "integrity": "sha512-qq7wOSsdP2b4rXEapWNmsCjpaTGZWtp9kZmri98GYCDZqN8UJUG5zSue4XtYWWJMWKJVE/hkaIwk+BgN1ZUn0Q==", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "1.5.2", + "@node-rs/argon2-android-arm64": "1.5.2", + "@node-rs/argon2-darwin-arm64": "1.5.2", + "@node-rs/argon2-darwin-x64": "1.5.2", + "@node-rs/argon2-freebsd-x64": "1.5.2", + "@node-rs/argon2-linux-arm-gnueabihf": "1.5.2", + "@node-rs/argon2-linux-arm64-gnu": "1.5.2", + "@node-rs/argon2-linux-arm64-musl": "1.5.2", + "@node-rs/argon2-linux-x64-gnu": "1.5.2", + "@node-rs/argon2-linux-x64-musl": "1.5.2", + "@node-rs/argon2-win32-arm64-msvc": "1.5.2", + "@node-rs/argon2-win32-ia32-msvc": "1.5.2", + "@node-rs/argon2-win32-x64-msvc": "1.5.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-1.5.2.tgz", + "integrity": "sha512-vVZec4ITr9GumAy0p8Zj8ozie362gtbZrTkLp9EqvuFZ/HrZzR09uS2IsDgm4mAstg/rc4A1gLRrHI8jDdbjkA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-1.5.2.tgz", + "integrity": "sha512-SwhnsXyrpgtWDTwYds1WUnxLA/kVP8HVaImYwQ3Wemqj1lkzcSoIaNyjNWkyrYGqO1tVc1YUrqsbd5eCHh+3sg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-1.5.2.tgz", + "integrity": "sha512-+1ZMKiCCv2pip/o1Xg09piQru2LOIBPQ1vS4is86f55N3jjZnSfP+db5mYCSRuB0gRYqui98he7su7OGXlF4gQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-1.5.2.tgz", + "integrity": "sha512-mQ57mORlsxpfjcEsVpiHyHCOp6Ljrz/rVNWk8ihnPWw0qt0EqF1zbHRxTEPemL1iBHL9UyXpXrKS4JKq6xMn5w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-1.5.2.tgz", + "integrity": "sha512-UjKbFd3viYcpiwflkU4haEdNUMk1V2fVCJImWLWQns/hVval9BrDv5xsBwgdynbPHDlPOiWj816LBQwhWLGVWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-1.5.2.tgz", + "integrity": "sha512-36GJjJBnVuscV9CTn8RVDeJysnmIzr6Lp7QBCDczYHi6eKFuA8udCJb4SRyJqdvIuzycKG1RL56FbcFBJYCYIA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-1.5.2.tgz", + "integrity": "sha512-sE0ydb2gp6xC+5vbVz8l3paaiBbFQIB2Rwp5wx9MmKiYdTfcO5WkGeADuSgoFiTcSEz1RsHXqrdVy6j/LtSqtA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-1.5.2.tgz", + "integrity": "sha512-LhE0YHB0aJCwlbsQrwePik/KFWUc9qMriJIL5KiejK3bDoTVY4ihH587QT56JyaLvl3nBJaAV8l5yMqQdHnouA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-1.5.2.tgz", + "integrity": "sha512-MnKLiBlyg05pxvKXe3lNgBL9El9ThD74hvVEiWH1Xk40RRrJ507NCOWXVmQ0FDq1mjTeGFxbIvk+AcoF0NSLIQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-1.5.2.tgz", + "integrity": "sha512-tzLgASY0Ng2OTW7Awwl9UWzjbWx8/uD6gXcZ/k/nYGSZE5Xp8EOD2NUqHLbK6KZE3775A0R25ShpiSxCadYqkg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-1.5.2.tgz", + "integrity": "sha512-vpTwSvv3oUXTpWZh0/HxdJ5wFMlmS7aVDwL4ATWepTZhMG4n+TO0+tVLdcPHCbg0oc6hCWBjWNPlSn9mW+YIgA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-1.5.2.tgz", + "integrity": "sha512-KPpZR15ui7uQWQXKmtaKyUQRs4UJdXnIIfiyFLGmLWCdEKlr3MtIGFt0fdziu4BF5ZObD8Ic6QvT0VXK4OJiww==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-1.5.2.tgz", + "integrity": "sha512-/pGuwixJS8ZlpwhX9iM6g6JEeZYo1TtnNf8exwsOi7gxcUoTUfw5it+5GfbY/n+xRBz/DIU4bzUmXmh+7Gh0ug==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.7.3.tgz", + "integrity": "sha512-BF6u9CBPUiyk1zU+5iwikezf+xM4MFSu5cmrrg/PLKffGgIM13ZsY6DHftcTraETB04ryasjM/5IejotH+sO5Q==", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/bcrypt-android-arm-eabi": "1.7.3", + "@node-rs/bcrypt-android-arm64": "1.7.3", + "@node-rs/bcrypt-darwin-arm64": "1.7.3", + "@node-rs/bcrypt-darwin-x64": "1.7.3", + "@node-rs/bcrypt-freebsd-x64": "1.7.3", + "@node-rs/bcrypt-linux-arm-gnueabihf": "1.7.3", + "@node-rs/bcrypt-linux-arm64-gnu": "1.7.3", + "@node-rs/bcrypt-linux-arm64-musl": "1.7.3", + "@node-rs/bcrypt-linux-x64-gnu": "1.7.3", + "@node-rs/bcrypt-linux-x64-musl": "1.7.3", + "@node-rs/bcrypt-win32-arm64-msvc": "1.7.3", + "@node-rs/bcrypt-win32-ia32-msvc": "1.7.3", + "@node-rs/bcrypt-win32-x64-msvc": "1.7.3" + } + }, + "node_modules/@node-rs/bcrypt-android-arm-eabi": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.7.3.tgz", + "integrity": "sha512-l53RuBqnqNvBN2jx09Ws6jpLmuQdSDx10n0GeaTfwh1svxsC8bPpVmxkfBExsT2Tu7KF38gTnPZvwsxysZQyPQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-android-arm64": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.7.3.tgz", + "integrity": "sha512-TZpm4VbiViqDMvusrcYzLr1b1M5FDF0cDNiTUciLeBSsKtU5lNdEZGAU7gvCnrKoUWpGuOblHU7613zuB7SiNQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-arm64": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.7.3.tgz", + "integrity": "sha512-SiUuAabynVsmixZMjh5xrn8w47EnV0HzbW9st4DPoVhn/wzdUcksIXDY75aoQG2EIzKLN8IGb+CIVnPGmRyhxw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-x64": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.7.3.tgz", + "integrity": "sha512-R+81Z0eX4hZPvCXY5Z6l0l+JrTU3WcSYGHP0QYV9uwdaafOz6EhrCXUzZ02AIcAbNoVR8eucYVruq9PiasXoVw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-freebsd-x64": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.7.3.tgz", + "integrity": "sha512-0pItU/5K3e83JjcJj9fZv+78txUoZ3hHCT7n/UMdu9mkpUzhX/rqb4jmQpJpD+UQoR76xp3qDo5RMgQBffBVNg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.7.3.tgz", + "integrity": "sha512-HTSybWUjNe8rWuXkTkMeFDiQNHc6VioRcgv6AeHZphIxiT6dFbnhXNkfz4Hr0zxvyPhZ3NrYjT2AmPVFT6VW3Q==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-gnu": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.7.3.tgz", + "integrity": "sha512-rWep6Y+v/c4bZHaM8LmSsrMwMmDR9wG4/q+3Z9VzR8xdnt5VCbuQdYWpf3sgGRGjTRdTBAdSK8x1reOjqsJ3Jg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-musl": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.7.3.tgz", + "integrity": "sha512-TyWEKhxr+yfGcMKzVV/ARZw+Hrky2yl91bo0XYU2ZW6I6LDC0emNsXugdWjwz8ADI4OWhhrOjXD8GCilxiB2Rg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-gnu": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.7.3.tgz", + "integrity": "sha512-PofxM1Qg7tZKj1oP0I7tBTSSLr8Xc2uxx+P3pBCPmYzaBwWqGteNHJlF7n2q5xiH7YOlguH4w5CmcEjsiA3K4A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-musl": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.7.3.tgz", + "integrity": "sha512-D5V6/dDVKP8S/ieDBLGhTn4oTo3upbrpWInynbhOMjJvPiIxVG1PiI3MXkWBtG9qtfleDk7gUkEKtAOxlIxDTQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-arm64-msvc": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.7.3.tgz", + "integrity": "sha512-b4gH2Yj5R4TwULrfMHd1Qqr+MrnFjVRUAJujDKPqi+PppSqezW8QF6DRSOL4GjnBmz5JEd64wxgeidvy7dsbGw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-ia32-msvc": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.7.3.tgz", + "integrity": "sha512-E91ro+ybI0RhNc89aGaZQGll0YhPoHr8JacoWrNKwhg9zwNOYeuO0tokdMZdm6nF0/8obll0Mq7wO9AXO9iffw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-x64-msvc": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.7.3.tgz", + "integrity": "sha512-LO/p9yjPODj/pQvPnowBuwpDdqiyUXQbqL1xb1RSP3NoyCFAGmjL5h0plSQrhLh8hskQiozBRXNaQurtsM7o0Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4886,6 +5349,35 @@ } } }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.0.4.tgz", + "integrity": "sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-toggle": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", @@ -5074,237 +5566,18 @@ "@babel/runtime": "^7.13.10" } }, - "node_modules/@react-email/body": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.2.tgz", - "integrity": "sha512-SqZrZdxZlH7viwnrLvrMnVzOKpiofVAtho09bmm2siDzy0VMDGItXRzUPLcpg9vcbVJCHZRCIKoNXqA+PtokzQ==", - "dependencies": { - "react": "18.2.0" - } - }, - "node_modules/@react-email/button": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.9.tgz", - "integrity": "sha512-eYWQ1X4RFlkKYYSPgSrT6rk98wuLOieEAGENrp9j37t1v/1C+jMmBu0UjZvwHsHWdbOMRjbVDFeMI/+MxWKSEg==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/column": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.7.tgz", - "integrity": "sha512-B29wVXyIcuVprgGpLkR23waPh/twlqmugZQsCKk05JlMCQ80/Puv4Lgj4dRsIJzgyTLMwG6xq17+Uxc5iGfuaQ==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/components": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.7.tgz", - "integrity": "sha512-GpRKV8E7EvK9OPf61f5Z8hliB3p0hTot8tslmEUVCTtX7tdL0wM2YEcZiDWU4PJcudJ/QWHJ7Y5wGzNEARcooA==", - "dependencies": { - "@react-email/body": "0.0.2", - "@react-email/button": "0.0.9", - "@react-email/column": "0.0.7", - "@react-email/container": "0.0.8", - "@react-email/font": "0.0.2", - "@react-email/head": "0.0.5", - "@react-email/heading": "0.0.8", - "@react-email/hr": "0.0.5", - "@react-email/html": "0.0.4", - "@react-email/img": "0.0.5", - "@react-email/link": "0.0.5", - "@react-email/preview": "0.0.6", - "@react-email/render": "0.0.7", - "@react-email/row": "0.0.5", - "@react-email/section": "0.0.9", - "@react-email/tailwind": "0.0.8", - "@react-email/text": "0.0.5", - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/container": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.8.tgz", - "integrity": "sha512-MQZQxvTOoLWjJR+Jm689jltm0I/mtZbEaDnwZbNkkHKgccr++wwb9kOKMgXG777Y7tGa1JATAsZpvFYiCITwUg==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/font": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.2.tgz", - "integrity": "sha512-mmkyOCAcbgytE7DfIuOBVG1YVDUZY9rPCor4o7pUEzGJiU2y/TNuV8CgNPSU/VgXeBKL/94QDjB62OrGHlFNMQ==", - "dependencies": { - "react": "18.2.0" - } - }, - "node_modules/@react-email/head": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.5.tgz", - "integrity": "sha512-s84OxJxZMee2z5b1a+RVwY1NOSUNNf1ecjPf6n64aZmMNcNUyn4gOl7RO6xbfBrZko7TigBwsFB1Cgjxtn/ydg==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/heading": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.8.tgz", - "integrity": "sha512-7atATmoHBHTk7hFYFsFFzOIBV3u1zPpsSOWkLBojdjSUdenpk2SbX8GP8/3aBhWl/tuFX9RBGcu1Xes+ZijFLg==", - "dependencies": { - "@radix-ui/react-slot": "1.0.0", - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/heading/node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz", - "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@react-email/heading/node_modules/@radix-ui/react-slot": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz", - "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.0" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/@react-email/hr": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.5.tgz", - "integrity": "sha512-nwB8GmSdvPlR/bWjDS07yHtgdfJqtvCaPXee3SVUY69YYP7NeDO/VACJlgrS9V2l79bj1lUpH0MJMU6MNAk5FQ==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/html": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.4.tgz", - "integrity": "sha512-7tRYSnudYAWez+NkPWOM8yLZH7EuYFtYdiLPnzpD+pf4cdk16Gz4up531DaIX6dNBbfbyEFpQxhXZxGeJ5ZkfQ==", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/img": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.5.tgz", - "integrity": "sha512-9ziFgBfrIAL+DpVlsraFcd2KwsTRyobLpqTnoiBYCcVZGod59xbYkmsmB3CbUosmLwPYg6AeD7Q7e+hCiwkWgg==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/link": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.5.tgz", - "integrity": "sha512-z+QW9f4gXBdyfhl7iYMY3td+rXKeZYK/2AGElEMsxVoywn5D0b6cF8m5w2jbf0U2V3enT+zy9yc1R6AyT59NOg==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/preview": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.6.tgz", - "integrity": "sha512-mXDCc3NGpm/4W7gowBtjsTxYXowLNOLsJsYhIfrsjNJWGlVhVFB9uEHm55LjBLpxSG020g6/8LIrpJU6g22qvg==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@react-email/render": { - "version": "0.0.7", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.7.tgz", - "integrity": "sha512-hMMhxk6TpOcDC5qnKzXPVJoVGEwfm+U5bGOPH+MyTTlx0F02RLQygcATBKsbP7aI/mvkmBAZoFbgPIHop7ovug==", + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.9.tgz", + "integrity": "sha512-nrim7wiACnaXsGtL7GF6jp3Qmml8J6vAjAH88jkC8lIbfNZaCyuPQHANjyYIXlvQeAbsWADQJFZgOHUqFqjh/A==", "dependencies": { - "html-to-text": "9.0.3", + "html-to-text": "9.0.5", "pretty": "2.0.0", "react": "18.2.0", "react-dom": "18.2.0" }, "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/row": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.5.tgz", - "integrity": "sha512-dir5l1M7Z/1BQqQkUrKUPIIDPt6ueEf6ScMGoBOcUh+VNNqmnqJE2Q2CH5X3w2uo6a5X7tnVhoJHGa2KTKe8Sw==", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/section": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.9.tgz", - "integrity": "sha512-3EbcWJ1jUZrzquWSvXrv8Hbk9V+BGvLcMWQIli4NdIpQlddmlGKUYfXU2mB2d2pf+5ojqkGcFZZ9fWxycB84jQ==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/tailwind": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.0.8.tgz", - "integrity": "sha512-0BLjD5GpiyBK7YDlaDrjHIpj9eTrrZrMJud3f1UPoCZhS+0S/M8LcR8WMbQsR+8/aLGmiy4F4TGZuRQcsJEsFw==", - "dependencies": { - "html-react-parser": "3.0.9", - "react": "18.2.0", - "react-dom": "18.2.0", - "tw-to-css": "0.0.11" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@react-email/text": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.5.tgz", - "integrity": "sha512-LXhHiaC6oRRsNAfOzJDos4wQA22eIdVJvR6G7uu4QzUvYNOAatDMf89jRQcKGrxX7InkS640v8sHd9jl5ztM5w==", - "dependencies": { - "react": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" } }, "node_modules/@rushstack/eslint-patch": { @@ -5321,12 +5594,12 @@ } }, "node_modules/@selderee/plugin-htmlparser2": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.10.0.tgz", - "integrity": "sha512-gW69MEamZ4wk1OsOq1nG1jcyhXIQcnrsX5JwixVw/9xaiav8TCyjESAruu1Rz9yyInhgBXxkNwMeygKnN2uxNA==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", + "integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==", "dependencies": { "domhandler": "^5.0.3", - "selderee": "^0.10.0" + "selderee": "^0.11.0" }, "funding": { "url": "https://ko-fi.com/killymxi" @@ -6303,9 +6576,9 @@ } }, "node_modules/@types/json-schema": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", - "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==" + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -6410,9 +6683,9 @@ "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==" }, "node_modules/@types/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw==" + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.5.tgz", + "integrity": "sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==" }, "node_modules/@types/unist": { "version": "2.0.8", @@ -6471,6 +6744,86 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.8.0.tgz", + "integrity": "sha512-RYOJdlkTJIXW7GSldUIHqc/Hkto8E+fZN96dMIFhuTJcQwdRoGN2rEWA8U6oXbLo0qufH7NPElUb+MceHtz54g==", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.8.0", + "@typescript-eslint/utils": "6.8.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.8.0.tgz", + "integrity": "sha512-p5qOxSum7W3k+llc7owEStXlGmSl8FcGvhYt8Vjy7FqEnmkCVlM3P57XQEGj58oqaBWDQXbJDZxwUWMS/EAPNQ==", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.8.0.tgz", + "integrity": "sha512-ISgV0lQ8XgW+mvv5My/+iTUdRmGspducmQcDw5JxznasXNnZn3SKNrTRuMsEXv+V/O+Lw9AGcQCfVaOPCAk/Zg==", + "dependencies": { + "@typescript-eslint/types": "6.8.0", + "@typescript-eslint/visitor-keys": "6.8.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.8.0.tgz", + "integrity": "sha512-oqAnbA7c+pgOhW2OhGvxm0t1BULX5peQI/rLsNDpGM78EebV3C9IGbX5HNZabuZ6UQrYveCLjKo8Iy/lLlBkkg==", + "dependencies": { + "@typescript-eslint/types": "6.8.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/types": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", @@ -6511,6 +6864,100 @@ } } }, + "node_modules/@typescript-eslint/utils": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.8.0.tgz", + "integrity": "sha512-dKs1itdE2qFG4jr0dlYLQVppqTE+Itt7GmIf/vX6CSvsW+3ov8PbWauVKyyfNngokhIO9sKZeRGCUo1+N7U98Q==", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.8.0", + "@typescript-eslint/types": "6.8.0", + "@typescript-eslint/typescript-estree": "6.8.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.8.0.tgz", + "integrity": "sha512-xe0HNBVwCph7rak+ZHcFD6A+q50SMsFwcmfdjs9Kz4qDh5hWhaPhFjRs/SODEhroBI5Ruyvyz9LfwUJ624O40g==", + "dependencies": { + "@typescript-eslint/types": "6.8.0", + "@typescript-eslint/visitor-keys": "6.8.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.8.0.tgz", + "integrity": "sha512-p5qOxSum7W3k+llc7owEStXlGmSl8FcGvhYt8Vjy7FqEnmkCVlM3P57XQEGj58oqaBWDQXbJDZxwUWMS/EAPNQ==", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.8.0.tgz", + "integrity": "sha512-ISgV0lQ8XgW+mvv5My/+iTUdRmGspducmQcDw5JxznasXNnZn3SKNrTRuMsEXv+V/O+Lw9AGcQCfVaOPCAk/Zg==", + "dependencies": { + "@typescript-eslint/types": "6.8.0", + "@typescript-eslint/visitor-keys": "6.8.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.8.0.tgz", + "integrity": "sha512-oqAnbA7c+pgOhW2OhGvxm0t1BULX5peQI/rLsNDpGM78EebV3C9IGbX5HNZabuZ6UQrYveCLjKo8Iy/lLlBkkg==", + "dependencies": { + "@typescript-eslint/types": "6.8.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/visitor-keys": { "version": "5.62.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", @@ -6560,35 +7007,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-node": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", - "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", - "dependencies": { - "acorn": "^7.0.0", - "acorn-walk": "^7.0.0", - "xtend": "^4.0.2" - } - }, - "node_modules/acorn-node/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-node/node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/acorn-walk": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", @@ -8577,14 +8995,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/defined": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", - "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8643,22 +9053,6 @@ "node": ">=12" } }, - "node_modules/detective": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz", - "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==", - "dependencies": { - "acorn-node": "^1.8.2", - "defined": "^1.0.0", - "minimist": "^1.2.6" - }, - "bin": { - "detective": "bin/detective.js" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -9611,6 +10005,14 @@ "node": ">=12" } }, + "node_modules/eslint-rule-composer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz", + "integrity": "sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -10967,57 +11369,25 @@ "node": ">=10" } }, - "node_modules/html-dom-parser": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-3.1.3.tgz", - "integrity": "sha512-fI0yyNlIeSboxU+jnrA4v8qj4+M8SI9/q6AKYdwCY2qki22UtKCDTxvagHniECu7sa5/o2zFRdLleA67035lsA==", - "dependencies": { - "domhandler": "5.0.3", - "htmlparser2": "8.0.1" - } - }, - "node_modules/html-react-parser": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-3.0.9.tgz", - "integrity": "sha512-gOPZmaCMXNYu7Y9+58k2tLhTMXQ+QN8ctNFijzLuBxJaLZ6TsN+tUpN+MhbI+6nGaBCRGT2rpw6y/AqkTFZckg==", - "dependencies": { - "domhandler": "5.0.3", - "html-dom-parser": "3.1.3", - "react-property": "2.0.0", - "style-to-js": "1.1.3" - }, - "peerDependencies": { - "react": "0.14 || 15 || 16 || 17 || 18" - } - }, "node_modules/html-to-text": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.3.tgz", - "integrity": "sha512-hxDF1kVCF2uw4VUJ3vr2doc91pXf2D5ngKcNviSitNkhP9OMOaJkDrFIFL6RMvko7NisWTEiqGpQ9LAxcVok1w==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz", + "integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==", "dependencies": { - "@selderee/plugin-htmlparser2": "^0.10.0", - "deepmerge": "^4.2.2", + "@selderee/plugin-htmlparser2": "^0.11.0", + "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", - "htmlparser2": "^8.0.1", - "selderee": "^0.10.0" + "htmlparser2": "^8.0.2", + "selderee": "^0.11.0" }, "engines": { "node": ">=14" } }, - "node_modules/html-void-elements": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz", - "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/htmlparser2": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", - "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", + "node_modules/html-to-text/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -11027,9 +11397,18 @@ ], "dependencies": { "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", + "domhandler": "^5.0.3", "domutils": "^3.0.1", - "entities": "^4.3.0" + "entities": "^4.4.0" + } + }, + "node_modules/html-void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz", + "integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/http-errors": { @@ -11128,6 +11507,13 @@ "node": ">=14.0.0" } }, + "node_modules/immutable": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.4.tgz", + "integrity": "sha512-fsXeu4J4i6WNWSikpI88v/PcVflZz+6kMhUfIwc5SY+poQRPnaf5V7qds6SUyUN3cVxEzuCab7QIoLOQ+DQ1wA==", + "optional": true, + "peer": true + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -14327,6 +14713,15 @@ "node": ">=8" } }, + "node_modules/oslo": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/oslo/-/oslo-0.17.0.tgz", + "integrity": "sha512-UJHew6zFEkJYGWjO4/ARHnX+M7umhJ6IXc6cJA2AQ3BpFwqEqaKjySOfXYuNFQddzfP2zk1aG+xYQG1ODHKwfQ==", + "dependencies": { + "@node-rs/argon2": "^1.5.2", + "@node-rs/bcrypt": "^1.7.3" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -14432,12 +14827,12 @@ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" }, "node_modules/parseley": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.11.0.tgz", - "integrity": "sha512-VfcwXlBWgTF+unPcr7yu3HSSA6QUdDaDnrHcytVfj5Z8azAyKBDrYnSIfeSxlrEayndNcLmrXzg+Vxbo6DWRXQ==", + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", + "integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==", "dependencies": { "leac": "^0.6.0", - "peberminta": "^0.8.0" + "peberminta": "^0.9.0" }, "funding": { "url": "https://ko-fi.com/killymxi" @@ -14547,9 +14942,9 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, "node_modules/peberminta": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.8.0.tgz", - "integrity": "sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", + "integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==", "funding": { "url": "https://ko-fi.com/killymxi" } @@ -15747,20 +16142,6 @@ "node": ">=12" } }, - "node_modules/react-email/node_modules/@react-email/render": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.6.tgz", - "integrity": "sha512-6zs7WZbd37TcPT1OmMPH/kcBpv0QSi+k3om7LyDnbdIcrbwOO/OstVwUaa/6zgvDvnq9Y2wOosbru7j5kUrW9A==", - "dependencies": { - "html-to-text": "9.0.3", - "pretty": "2.0.0", - "react": "18.2.0", - "react-dom": "18.2.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/react-email/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -15857,6 +16238,15 @@ "react": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-hotkeys-hook": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.4.1.tgz", + "integrity": "sha512-sClBMBioFEgFGYLTWWRKvhxcCx1DRznd+wkFHwQZspnRBkHTgruKIHptlK/U/2DPX8BhHoRGzpMVWUXMmdZlmw==", + "peerDependencies": { + "react": ">=16.8.1", + "react-dom": ">=16.8.1" + } + }, "node_modules/react-icons": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz", @@ -15875,11 +16265,6 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, - "node_modules/react-property": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz", - "integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw==" - }, "node_modules/react-remove-scroll": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", @@ -16458,28 +16843,16 @@ } }, "node_modules/resend": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/resend/-/resend-1.1.0.tgz", - "integrity": "sha512-it8TIDVT+/gAiJsUlv2tdHuvzwCCv4Zwu+udDqIm/dIuByQwe68TtFDcPccxqpSVVrNCBxxXLzsdT1tsV+P3GA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-2.0.0.tgz", + "integrity": "sha512-jAh0DN84ZjjmzGM2vMjJ1hphPBg1mG98dzopF7kJzmin62v8ESg4og2iCKWdkAboGOT2SeO5exbr/8Xh8gLddw==", "dependencies": { - "@react-email/render": "0.0.7", - "type-fest": "3.13.0" + "@react-email/render": "0.0.9" }, "engines": { "node": ">=18" } }, - "node_modules/resend/node_modules/type-fest": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.0.tgz", - "integrity": "sha512-Gur3yQGM9qiLNs0KPP7LPgeRbio2QTt4xXouobMCarR0/wyW3F+F/+OWwshg3NG0Adon7uQfSZBpB46NfhoF1A==", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -16748,6 +17121,24 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/sass": { + "version": "1.69.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.4.tgz", + "integrity": "sha512-+qEreVhqAy8o++aQfCJwp0sklr2xyEzkm9Pp/Igu9wNPoe7EZEQ8X/MBvvXggI2ql607cxKg/RKOwDj6pp2XDA==", + "optional": true, + "peer": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -16769,11 +17160,11 @@ } }, "node_modules/selderee": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.10.0.tgz", - "integrity": "sha512-DEL/RW/f4qLw/NrVg97xKaEBC8IpzIG2fvxnzCp3Z4yk4jQ3MXom+Imav9wApjxX2dfS3eW7x0DXafJr85i39A==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", + "integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==", "dependencies": { - "parseley": "^0.11.0" + "parseley": "^0.12.0" }, "funding": { "url": "https://ko-fi.com/killymxi" @@ -17437,22 +17828,6 @@ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" }, - "node_modules/style-to-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz", - "integrity": "sha512-zKI5gN/zb7LS/Vm0eUwjmjrXWw8IMtyA8aPBJZdYiQTXj4+wQ3IucOLIOnF7zCHxvW8UhIGh/uZh/t9zEHXNTQ==", - "dependencies": { - "style-to-object": "0.4.1" - } - }, - "node_modules/style-to-js/node_modules/style-to-object": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz", - "integrity": "sha512-HFpbb5gr2ypci7Qw+IOhnP2zOU7e77b+rzM+wTzXzfi1PrtBCX0E7Pk4wL4iTLnhzZ+JgEGAhX81ebTg/aYjQw==", - "dependencies": { - "inline-style-parser": "0.1.1" - } - }, "node_modules/style-to-object": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", @@ -18161,199 +18536,6 @@ "win32" ] }, - "node_modules/tw-to-css": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/tw-to-css/-/tw-to-css-0.0.11.tgz", - "integrity": "sha512-uIJuEBIwyHzZg9xyGyEgDWHIkbAwEC4bhEHQ4THPuN5SToR7Zlhes5ffMjqtrv+WdtTmudTHTdc9VwUldy0FxQ==", - "dependencies": { - "postcss": "8.4.21", - "postcss-css-variables": "0.18.0", - "tailwindcss": "3.2.7" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/tw-to-css/node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" - }, - "node_modules/tw-to-css/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/tw-to-css/node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "engines": { - "node": ">= 6" - } - }, - "node_modules/tw-to-css/node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - } - ], - "dependencies": { - "nanoid": "^3.3.4", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/tw-to-css/node_modules/postcss-import": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", - "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/tw-to-css/node_modules/postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "dependencies": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/tw-to-css/node_modules/postcss-nested": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.0.tgz", - "integrity": "sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==", - "dependencies": { - "postcss-selector-parser": "^6.0.10" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/tw-to-css/node_modules/postcss-selector-parser": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", - "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/tw-to-css/node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tw-to-css/node_modules/tailwindcss": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.7.tgz", - "integrity": "sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==", - "dependencies": { - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "color-name": "^1.1.4", - "detective": "^5.2.1", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.2.12", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "lilconfig": "^2.0.6", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.0.9", - "postcss-import": "^14.1.0", - "postcss-js": "^4.0.0", - "postcss-load-config": "^3.1.4", - "postcss-nested": "6.0.0", - "postcss-selector-parser": "^6.0.11", - "postcss-value-parser": "^4.2.0", - "quick-lru": "^5.1.1", - "resolve": "^1.22.1" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=12.13.0" - }, - "peerDependencies": { - "postcss": "^8.0.9" - } - }, - "node_modules/tw-to-css/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "engines": { - "node": ">= 6" - } - }, "node_modules/tween-functions": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/tween-functions/-/tween-functions-1.2.0.tgz", @@ -18676,6 +18858,11 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uqr": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", + "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -19360,11 +19547,11 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@documenso/nodemailer-resend": "1.0.0", - "@react-email/components": "^0.0.7", + "@documenso/nodemailer-resend": "2.0.0", + "@react-email/components": "^0.0.11", "nodemailer": "^6.9.3", - "react-email": "^1.9.4", - "resend": "^1.1.0" + "react-email": "^1.9.5", + "resend": "^2.0.0" }, "devDependencies": { "@documenso/tailwind-config": "*", @@ -19373,6 +19560,298 @@ "tsup": "^7.1.0" } }, + "packages/email/node_modules/@react-email/body": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.4.tgz", + "integrity": "sha512-NmHOumdmyjWvOXomqhQt06KbgRxhHrVznxQp/oWiPWes8nAJo2Y4L27aPHR9nTcs7JF7NmcJe9YSN42pswK+GQ==", + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/button": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.11.tgz", + "integrity": "sha512-mB5ySfZifwE5ybtIWwXGbmKk1uKkH4655gftL4+mMxZAZCkINVa2KXTi5pO+xZhMtJI9xtAsikOrOEU1gTDoww==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/column": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.8.tgz", + "integrity": "sha512-blChqGU8e/L6KZiB5EPww8bkZfdyHDuS0vKIvU+iS14uK+xfAw+5P5CU9BYXccEuJh2Gftfngu1bWMFp2Sc6ag==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/components": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.11.tgz", + "integrity": "sha512-wj9Sra/AGQvadb3ZABz44ll9Fb9FvXPEmODXRWbNRSc8pJTFGWorrsm4M/yj8dnewd4HtnbLkV1eDOvuiLAVLA==", + "dependencies": { + "@react-email/body": "0.0.4", + "@react-email/button": "0.0.11", + "@react-email/column": "0.0.8", + "@react-email/container": "0.0.10", + "@react-email/font": "0.0.4", + "@react-email/head": "0.0.6", + "@react-email/heading": "0.0.9", + "@react-email/hr": "0.0.6", + "@react-email/html": "0.0.6", + "@react-email/img": "0.0.6", + "@react-email/link": "0.0.6", + "@react-email/preview": "0.0.7", + "@react-email/render": "0.0.9", + "@react-email/row": "0.0.6", + "@react-email/section": "0.0.10", + "@react-email/tailwind": "0.0.12", + "@react-email/text": "0.0.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/container": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.10.tgz", + "integrity": "sha512-goishY7ocq+lord0043/LZK268bqvMFW/sxpUt/dSCPJyrrZZNCbpW2t8w8HztU38cYj0qGQLxO5Qvpn/RER3w==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/font": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.4.tgz", + "integrity": "sha512-rN/pFlAcDNmfYFxpufT/rFRrM5KYBJM4nTA2uylTehlVOro6fb/q6n0zUwLF6OmQ4QIuRbqdEy7DI9mmJiNHxA==", + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/head": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.6.tgz", + "integrity": "sha512-9BrBDalb34nBOmmQVQc7/pjJotcuAeC3rhBl4G88Ohiipuv15vPIKqwy8vPJcFNi4l7yGlitfG3EESIjkLkoIw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/heading": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.9.tgz", + "integrity": "sha512-xzkcGlm+/aFrNlJZBKzxRKkRYJ2cRx92IqmSKAuGnwuKQ/uMKomXzPsHPu3Dclmnhn3wVKj4uprkgQOoxP6uXQ==", + "dependencies": { + "@radix-ui/react-slot": "1.0.2", + "react": "18.2.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/email/node_modules/@react-email/hr": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.6.tgz", + "integrity": "sha512-W+wINBz7z7BRv3i9GS+QoJBae1PESNhv6ZY6eLnEpqtBI/2++suuRNJOU/KpZzE6pykeTp6I/Z7UcL0LEYKgyg==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/html": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.6.tgz", + "integrity": "sha512-8Fo20VOqxqc087gGEPjT8uos06fTXIC8NSoiJxpiwAkwiKtQnQH/jOdoLv6XaWh5Zt2clj1uokaoklnaM5rY1w==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/img": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.6.tgz", + "integrity": "sha512-Wd7xKI3b1Jvb2ZEHyVpJ9D98u0GHrRl+578b8LV24PavM/65V61Q5LN5Fr9sAhj+4VGqnHDIVeXIYEzVbWaa3Q==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/link": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.6.tgz", + "integrity": "sha512-bYYHroWGS//nDl9yhh8V6K2BrNwAsyX7N/XClSCRku3x56NrZ6D0nBKWewYDPlJ9rW9TIaJm1jDYtO9XBzLlkQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/preview": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.7.tgz", + "integrity": "sha512-YLfIwHdexPi8IgP1pSuVXdAmKzMQ8ctCCLEjkMttT2vkSFqT6m/e6UFWK2l30rKm2dDsLvQyEvo923mPXjnNzg==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/row": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.6.tgz", + "integrity": "sha512-msJ2TnDJNwpgDfDzUO63CvhusJHeaGLMM+8Zz86VPvxzwe/DkT7N48QKRWRCkt8urxVz5U+EgivORA9Dum9p3Q==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/section": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.10.tgz", + "integrity": "sha512-x9B2KYFqj+d8I1fK9bgeVm/3mLE4Qgn4mm/GbDtcJeSzKU/G7bTb7/3+BMDk9SARPGkg5XAuZm1XgcqQQutt2A==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/tailwind": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-0.0.12.tgz", + "integrity": "sha512-s8Ch7GL30qRKScn9NWwItMqxjtzbyUtCnXfC6sL2YTVtulbfvZZ06W+aA0S6f7fdrVlOOlQzZuK/sVaQCHhcSw==", + "dependencies": { + "react": "18.2.0", + "react-dom": "18.2.0", + "tw-to-css": "0.0.12" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/@react-email/text": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.6.tgz", + "integrity": "sha512-PDUTAD1PjlzXFOIUrR1zuV2xxguL62yne5YLcn1k+u/dVUyzn6iU/5lFShxCfzuh3QDWCf4+JRNnXN9rmV6jzw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "18.2.0" + } + }, + "packages/email/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "packages/email/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "packages/email/node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "packages/email/node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "packages/email/node_modules/tailwindcss": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", + "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "postcss-value-parser": "^4.2.0", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "packages/email/node_modules/tw-to-css": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/tw-to-css/-/tw-to-css-0.0.12.tgz", + "integrity": "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw==", + "dependencies": { + "postcss": "8.4.31", + "postcss-css-variables": "0.18.0", + "tailwindcss": "3.3.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "packages/eslint-config": { "name": "@documenso/eslint-config", "version": "0.0.0", @@ -19387,6 +19866,7 @@ "eslint-plugin-package-json": "^0.1.4", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.32.2", + "eslint-plugin-unused-imports": "^3.0.0", "typescript": "5.2.2" } }, @@ -19489,32 +19969,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "packages/eslint-config/node_modules/@typescript-eslint/type-utils": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.8.0.tgz", - "integrity": "sha512-RYOJdlkTJIXW7GSldUIHqc/Hkto8E+fZN96dMIFhuTJcQwdRoGN2rEWA8U6oXbLo0qufH7NPElUb+MceHtz54g==", - "dependencies": { - "@typescript-eslint/typescript-estree": "6.8.0", - "@typescript-eslint/utils": "6.8.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "packages/eslint-config/node_modules/@typescript-eslint/types": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.8.0.tgz", @@ -19567,44 +20021,6 @@ "node": ">=10" } }, - "packages/eslint-config/node_modules/@typescript-eslint/utils": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.8.0.tgz", - "integrity": "sha512-dKs1itdE2qFG4jr0dlYLQVppqTE+Itt7GmIf/vX6CSvsW+3ov8PbWauVKyyfNngokhIO9sKZeRGCUo1+N7U98Q==", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.8.0", - "@typescript-eslint/types": "6.8.0", - "@typescript-eslint/typescript-estree": "6.8.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "packages/eslint-config/node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "packages/eslint-config/node_modules/@typescript-eslint/visitor-keys": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.8.0.tgz", @@ -19710,6 +20126,26 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, + "packages/eslint-config/node_modules/eslint-plugin-unused-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.0.0.tgz", + "integrity": "sha512-sduiswLJfZHeeBJ+MQaG+xYzSWdRXoSw61DpU13mzWumCkR0ufD0HmO4kdNokjrkluMHpj/7PJeN35pgbhW3kw==", + "dependencies": { + "eslint-rule-composer": "^0.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^6.0.0", + "eslint": "^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, "packages/eslint-config/node_modules/resolve": { "version": "2.0.0-next.5", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", @@ -19747,6 +20183,8 @@ "@documenso/prisma": "*", "@documenso/signing": "*", "@next-auth/prisma-adapter": "1.0.7", + "@noble/ciphers": "0.4.0", + "@noble/hashes": "1.3.2", "@pdf-lib/fontkit": "^1.1.1", "@scure/base": "^1.1.3", "@sindresorhus/slugify": "^2.2.1", @@ -19756,6 +20194,7 @@ "nanoid": "^4.0.2", "next": "14.0.0", "next-auth": "4.24.3", + "oslo": "^0.17.0", "pdf-lib": "^1.17.1", "react": "18.2.0", "remeda": "^1.27.1", @@ -19855,7 +20294,8 @@ "@trpc/server": "^10.36.0", "superjson": "^1.13.1", "zod": "^3.22.4" - } + }, + "devDependencies": {} }, "packages/tsconfig": { "name": "@documenso/tsconfig", @@ -19894,6 +20334,7 @@ "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toast": "^1.1.3", "@radix-ui/react-toggle": "^1.0.2", + "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.6", "@tanstack/react-table": "^8.9.1", "class-variance-authority": "^0.6.0", diff --git a/package.json b/package.json index 5bbd4f431..d21af733e 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing", "start": "cd apps && cd web && next start", "lint": "turbo run lint", + "lint:fix": "turbo run lint:fix", "format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"", "prepare": "husky install", "commitlint": "commitlint --edit", @@ -46,6 +47,7 @@ "packages/*" ], "dependencies": { - "recharts": "^2.7.2" + "recharts": "^2.7.2", + "react-hotkeys-hook": "^4.4.1" } } diff --git a/packages/ee/server-only/limits/handler.ts b/packages/ee/server-only/limits/handler.ts index f4b1bd4af..69f77db75 100644 --- a/packages/ee/server-only/limits/handler.ts +++ b/packages/ee/server-only/limits/handler.ts @@ -30,7 +30,7 @@ export const limitsHandler = async ( }); } - res.status(500).json({ + return res.status(500).json({ error: ERROR_CODES.UNKNOWN, }); } diff --git a/packages/email/package.json b/packages/email/package.json index 4b23512ce..6366c67ed 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -17,11 +17,11 @@ "worker:test": "tsup worker/index.ts --format esm" }, "dependencies": { - "@documenso/nodemailer-resend": "1.0.0", - "@react-email/components": "^0.0.7", + "@documenso/nodemailer-resend": "2.0.0", + "@react-email/components": "^0.0.11", "nodemailer": "^6.9.3", - "react-email": "^1.9.4", - "resend": "^1.1.0" + "react-email": "^1.9.5", + "resend": "^2.0.0" }, "devDependencies": { "@documenso/tailwind-config": "*", diff --git a/packages/email/template-components/template-confirmation-email.tsx b/packages/email/template-components/template-confirmation-email.tsx new file mode 100644 index 000000000..e46582f54 --- /dev/null +++ b/packages/email/template-components/template-confirmation-email.tsx @@ -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 ( + + + +
+ + Welcome to Documenso! + + + + Before you get started, please confirm your email address by clicking the button below: + + +
+ + + You can also copy and paste this link into your browser: {confirmationLink} (link + expires in 1 hour) + +
+
+
+ ); +}; diff --git a/packages/email/templates/confirm-email.tsx b/packages/email/templates/confirm-email.tsx new file mode 100644 index 000000000..5e917f0a3 --- /dev/null +++ b/packages/email/templates/confirm-email.tsx @@ -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 ( + + + {previewText} + + +
+ +
+ Documenso Logo + + +
+
+
+ + + + +
+ +
+ + ); +}; diff --git a/packages/eslint-config/index.cjs b/packages/eslint-config/index.cjs index c4cfae3c3..57cecf40d 100644 --- a/packages/eslint-config/index.cjs +++ b/packages/eslint-config/index.cjs @@ -2,14 +2,13 @@ module.exports = { extends: [ 'next', 'turbo', - 'prettier', 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', 'plugin:package-json/recommended', ], - plugins: ['prettier', 'package-json'], + plugins: ['prettier', 'package-json', 'unused-imports'], env: { node: true, @@ -30,12 +29,22 @@ module.exports = { }, rules: { + '@next/next/no-html-link-for-pages': 'off', 'react/no-unescaped-entities': 'off', - 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-unused-vars': 'off', + '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': [ 'error', { @@ -67,5 +76,14 @@ module.exports = { // To handle this we want this rule to catch usages and highlight them as // warnings so we can write appropriate interfaces and guards later. '@typescript-eslint/consistent-type-assertions': ['warn', { assertionStyle: 'never' }], + + '@typescript-eslint/consistent-type-imports': [ + 'warn', + { + prefer: 'type-imports', + fixStyle: 'separate-type-imports', + disallowTypeAnnotations: false, + }, + ], }, }; diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 1fcf8aa1e..f80719aa1 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -16,6 +16,7 @@ "eslint-plugin-package-json": "^0.1.4", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-react": "^7.32.2", + "eslint-plugin-unused-imports": "^3.0.0", "typescript": "5.2.2" } } diff --git a/packages/lib/constants/crypto.ts b/packages/lib/constants/crypto.ts new file mode 100644 index 000000000..d911cd6cf --- /dev/null +++ b/packages/lib/constants/crypto.ts @@ -0,0 +1 @@ +export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY; diff --git a/packages/lib/constants/keyboard-shortcuts.ts b/packages/lib/constants/keyboard-shortcuts.ts new file mode 100644 index 000000000..896b4abf5 --- /dev/null +++ b/packages/lib/constants/keyboard-shortcuts.ts @@ -0,0 +1,2 @@ +export const SETTINGS_PAGE_SHORTCUT = 'N+S'; +export const DOCUMENTS_PAGE_SHORTCUT = 'N+D'; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index ceaab9e7f..57f7bd163 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -7,6 +7,8 @@ import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google'; 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 { ErrorCode } from './error-codes'; @@ -22,13 +24,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { credentials: { email: { label: 'Email', type: 'email' }, 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) => { if (!credentials) { throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND); } - const { email, password } = credentials; + const { email, password, backupCode, totpCode } = credentials; const user = await getUserByEmail({ email }).catch(() => { throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD); @@ -44,6 +52,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { 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 { id: Number(user.id), email: user.email, @@ -88,11 +110,13 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { merged.id = retrieved.id; merged.name = retrieved.name; merged.email = retrieved.email; + merged.emailVerified = retrieved.emailVerified; } if ( - !merged.lastSignedIn || - DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now() + merged.id && + (!merged.lastSignedIn || + DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now()) ) { merged.lastSignedIn = new Date().toISOString(); @@ -111,6 +135,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { name: merged.name, email: merged.email, lastSignedIn: merged.lastSignedIn, + emailVerified: merged.emailVerified, }; }, @@ -122,6 +147,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { id: Number(token.id), name: token.name, email: token.email, + emailVerified: + typeof token.emailVerified === 'string' ? new Date(token.emailVerified) : null, }, } satisfies Session; } diff --git a/packages/lib/next-auth/error-codes.ts b/packages/lib/next-auth/error-codes.ts index 26e8f5b97..c3dfafece 100644 --- a/packages/lib/next-auth/error-codes.ts +++ b/packages/lib/next-auth/error-codes.ts @@ -8,4 +8,15 @@ export const ErrorCode = { INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD', USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD', 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; diff --git a/packages/lib/next-auth/get-server-component-session.ts b/packages/lib/next-auth/get-server-component-session.ts new file mode 100644 index 000000000..7e35af5ad --- /dev/null +++ b/packages/lib/next-auth/get-server-component-session.ts @@ -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 }; +}); diff --git a/packages/lib/next-auth/get-server-session.ts b/packages/lib/next-auth/get-server-session.ts index f9196369f..215f754ac 100644 --- a/packages/lib/next-auth/get-server-session.ts +++ b/packages/lib/next-auth/get-server-session.ts @@ -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'; @@ -26,29 +28,3 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) => 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 }; -}; diff --git a/packages/lib/package.json b/packages/lib/package.json index 0b34caa48..b43d16a34 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -11,6 +11,8 @@ "next-auth/" ], "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", "clean": "rimraf node_modules" }, "dependencies": { @@ -22,6 +24,8 @@ "@documenso/prisma": "*", "@documenso/signing": "*", "@next-auth/prisma-adapter": "1.0.7", + "@noble/ciphers": "0.4.0", + "@noble/hashes": "1.3.2", "@pdf-lib/fontkit": "^1.1.1", "@scure/base": "^1.1.3", "@sindresorhus/slugify": "^2.2.1", @@ -31,6 +35,7 @@ "nanoid": "^4.0.2", "next": "14.0.0", "next-auth": "4.24.3", + "oslo": "^0.17.0", "pdf-lib": "^1.17.1", "react": "18.2.0", "remeda": "^1.27.1", diff --git a/packages/lib/server-only/2fa/disable-2fa.ts b/packages/lib/server-only/2fa/disable-2fa.ts new file mode 100644 index 000000000..5b27d5c9d --- /dev/null +++ b/packages/lib/server-only/2fa/disable-2fa.ts @@ -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; +}; diff --git a/packages/lib/server-only/2fa/enable-2fa.ts b/packages/lib/server-only/2fa/enable-2fa.ts new file mode 100644 index 000000000..9f61e52a4 --- /dev/null +++ b/packages/lib/server-only/2fa/enable-2fa.ts @@ -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 }; +}; diff --git a/packages/lib/server-only/2fa/get-backup-code.ts b/packages/lib/server-only/2fa/get-backup-code.ts new file mode 100644 index 000000000..e1188f37a --- /dev/null +++ b/packages/lib/server-only/2fa/get-backup-code.ts @@ -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; +}; diff --git a/packages/lib/server-only/2fa/is-2fa-availble.ts b/packages/lib/server-only/2fa/is-2fa-availble.ts new file mode 100644 index 000000000..d06a0085d --- /dev/null +++ b/packages/lib/server-only/2fa/is-2fa-availble.ts @@ -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' + ); +}; diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts new file mode 100644 index 000000000..30ddf0ec3 --- /dev/null +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -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, + }; +}; diff --git a/packages/lib/server-only/2fa/validate-2fa.ts b/packages/lib/server-only/2fa/validate-2fa.ts new file mode 100644 index 000000000..7fc76a8bb --- /dev/null +++ b/packages/lib/server-only/2fa/validate-2fa.ts @@ -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); +}; diff --git a/packages/lib/server-only/2fa/verify-2fa-token.ts b/packages/lib/server-only/2fa/verify-2fa-token.ts new file mode 100644 index 000000000..fa9159517 --- /dev/null +++ b/packages/lib/server-only/2fa/verify-2fa-token.ts @@ -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; +}; diff --git a/packages/lib/server-only/2fa/verify-backup-code.ts b/packages/lib/server-only/2fa/verify-backup-code.ts new file mode 100644 index 000000000..357d4994c --- /dev/null +++ b/packages/lib/server-only/2fa/verify-backup-code.ts @@ -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); +}; diff --git a/packages/lib/server-only/auth/hash.ts b/packages/lib/server-only/auth/hash.ts index 1de2ac458..df9931c97 100644 --- a/packages/lib/server-only/auth/hash.ts +++ b/packages/lib/server-only/auth/hash.ts @@ -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'; @@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth'; export const hashSync = (password: string) => { return bcryptHashSync(password, SALT_ROUNDS); }; + +export const compareSync = (password: string, hash: string) => { + return bcryptCompareSync(password, hash); +}; diff --git a/packages/lib/server-only/auth/send-confirmation-email.ts b/packages/lib/server-only/auth/send-confirmation-email.ts new file mode 100644 index 000000000..7defdb1bd --- /dev/null +++ b/packages/lib/server-only/auth/send-confirmation-email.ts @@ -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 }), + }); +}; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 2712c56fa..62db516fa 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -94,6 +94,7 @@ export const completeDocumentWithToken = async ({ }, data: { status: DocumentStatus.COMPLETED, + completedAt: new Date(), }, }); diff --git a/packages/lib/server-only/document/duplicate-document-by-id.ts b/packages/lib/server-only/document/duplicate-document-by-id.ts new file mode 100644 index 000000000..5d3bb9f9c --- /dev/null +++ b/packages/lib/server-only/document/duplicate-document-by-id.ts @@ -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; +}; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index aa5410c17..2581c5738 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -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 { Document, Prisma, SigningStatus } from '@documenso/prisma/client'; @@ -16,6 +17,7 @@ export interface FindDocumentsOptions { column: keyof Omit; direction: 'asc' | 'desc'; }; + period?: '' | '7d' | '14d' | '30d'; } export const findDocuments = async ({ @@ -25,6 +27,7 @@ export const findDocuments = async ({ page = 1, perPage = 10, orderBy, + period, }: FindDocumentsOptions) => { const user = await prisma.user.findFirstOrThrow({ where: { @@ -35,14 +38,16 @@ export const findDocuments = async ({ const orderByColumn = orderBy?.column ?? 'createdAt'; const orderByDirection = orderBy?.direction ?? 'desc'; - const termFilters = !term - ? undefined - : ({ + const termFilters = match(term) + .with(P.string.minLength(1), () => { + return { title: { contains: term, mode: 'insensitive', }, - } as const); + } as const; + }) + .otherwise(() => undefined); const filters = match(status) .with(ExtendedDocumentStatus.ALL, () => ({ @@ -113,12 +118,24 @@ export const findDocuments = async ({ })) .exhaustive(); + const whereClause = { + ...termFilters, + ...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: { - ...termFilters, - ...filters, - }, + where: whereClause, skip: Math.max(page - 1, 0) * perPage, take: perPage, orderBy: { diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx new file mode 100644 index 000000000..3069fc0ac --- /dev/null +++ b/packages/lib/server-only/document/resend-document.tsx @@ -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 }), + }); + }), + ]); +}; diff --git a/packages/lib/server-only/feature-flags/get.ts b/packages/lib/server-only/feature-flags/get.ts index 3157afb60..36aafc7b7 100644 --- a/packages/lib/server-only/feature-flags/get.ts +++ b/packages/lib/server-only/feature-flags/get.ts @@ -105,7 +105,7 @@ export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): st const config = extractPostHogConfig(); const email = jwt?.email; - const userId = jwt?.id.toString(); + const userId = jwt?.id?.toString(); let fallbackDistinctId = nanoid(); diff --git a/packages/lib/server-only/user/generate-confirmation-token.ts b/packages/lib/server-only/user/generate-confirmation-token.ts new file mode 100644 index 000000000..5676890ec --- /dev/null +++ b/packages/lib/server-only/user/generate-confirmation-token.ts @@ -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 }); +}; diff --git a/packages/lib/server-only/user/get-user-monthly-growth.ts b/packages/lib/server-only/user/get-user-monthly-growth.ts new file mode 100644 index 000000000..6cbb511b7 --- /dev/null +++ b/packages/lib/server-only/user/get-user-monthly-growth.ts @@ -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` + 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), + })); +}; diff --git a/packages/lib/server-only/user/send-confirmation-token.ts b/packages/lib/server-only/user/send-confirmation-token.ts new file mode 100644 index 000000000..5206d202e --- /dev/null +++ b/packages/lib/server-only/user/send-confirmation-token.ts @@ -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 }); +}; diff --git a/packages/lib/server-only/user/verify-email.ts b/packages/lib/server-only/user/verify-email.ts new file mode 100644 index 000000000..e954df1f8 --- /dev/null +++ b/packages/lib/server-only/user/verify-email.ts @@ -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; +}; diff --git a/packages/lib/universal/crypto.ts b/packages/lib/universal/crypto.ts new file mode 100644 index 000000000..405208d7f --- /dev/null +++ b/packages/lib/universal/crypto.ts @@ -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); +}; diff --git a/packages/lib/universal/unit-convertions.ts b/packages/lib/universal/unit-convertions.ts new file mode 100644 index 000000000..4dd97c949 --- /dev/null +++ b/packages/lib/universal/unit-convertions.ts @@ -0,0 +1,3 @@ +export function megabytesToBytes(megabytes: number) { + return megabytes * 1000000; +} diff --git a/packages/lib/universal/upload/server-actions.ts b/packages/lib/universal/upload/server-actions.ts index a2122aae2..ec0bde59a 100644 --- a/packages/lib/universal/upload/server-actions.ts +++ b/packages/lib/universal/upload/server-actions.ts @@ -10,7 +10,7 @@ import slugify from '@sindresorhus/slugify'; import path from 'node:path'; 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'; export const getPresignPostUrl = async (fileName: string, contentType: string) => { diff --git a/packages/prisma/helper.ts b/packages/prisma/helper.ts index 865e16239..3acd113fc 100644 --- a/packages/prisma/helper.ts +++ b/packages/prisma/helper.ts @@ -23,6 +23,11 @@ export const getDatabaseUrl = () => { 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 // mofifiable URL. const url = new URL(process.env.NEXT_PRIVATE_DATABASE_URL.replace('postgres://', 'https://')); diff --git a/packages/prisma/migrations/20231025074705_add_email_confirmation_registration/migration.sql b/packages/prisma/migrations/20231025074705_add_email_confirmation_registration/migration.sql new file mode 100644 index 000000000..95e64a744 --- /dev/null +++ b/packages/prisma/migrations/20231025074705_add_email_confirmation_registration/migration.sql @@ -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; diff --git a/packages/prisma/migrations/20231031072857_verify_existing_users/migration.sql b/packages/prisma/migrations/20231031072857_verify_existing_users/migration.sql new file mode 100644 index 000000000..5b082c233 --- /dev/null +++ b/packages/prisma/migrations/20231031072857_verify_existing_users/migration.sql @@ -0,0 +1,3 @@ +UPDATE "User" +SET "emailVerified" = CURRENT_TIMESTAMP +WHERE "emailVerified" IS NULL; diff --git a/packages/prisma/migrations/20231103044612_add_completed_date/migration.sql b/packages/prisma/migrations/20231103044612_add_completed_date/migration.sql new file mode 100644 index 000000000..39bfc01c8 --- /dev/null +++ b/packages/prisma/migrations/20231103044612_add_completed_date/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "Document" ADD COLUMN "completedAt" TIMESTAMP(3); + +UPDATE "Document" SET "completedAt" = "updatedAt" WHERE "status" = 'COMPLETED'; diff --git a/packages/prisma/migrations/20231105184518_add_2fa/migration.sql b/packages/prisma/migrations/20231105184518_add_2fa/migration.sql new file mode 100644 index 000000000..8456bdbc6 --- /dev/null +++ b/packages/prisma/migrations/20231105184518_add_2fa/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "twoFactorBackupCodes" TEXT, +ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "twoFactorSecret" TEXT; diff --git a/packages/prisma/package.json b/packages/prisma/package.json index bd33db657..2fb01a6ac 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -8,6 +8,7 @@ "build": "prisma generate", "format": "prisma format", "clean": "rimraf node_modules", + "post-install": "prisma generate", "prisma:generate": "prisma generate", "prisma:migrate-dev": "prisma migrate dev", "prisma:migrate-deploy": "prisma migrate deploy", diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f273f6c3f..7407bc5c0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -19,23 +19,27 @@ enum Role { } model User { - id Int @id @default(autoincrement()) - name String? - email String @unique - emailVerified DateTime? - password String? - source String? - signature String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - lastSignedIn DateTime @default(now()) - roles Role[] @default([USER]) - identityProvider IdentityProvider @default(DOCUMENSO) - accounts Account[] - sessions Session[] - Document Document[] - Subscription Subscription? - PasswordResetToken PasswordResetToken[] + id Int @id @default(autoincrement()) + name String? + email String @unique + emailVerified DateTime? + password String? + source String? + signature String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + lastSignedIn DateTime @default(now()) + roles Role[] @default([USER]) + identityProvider IdentityProvider @default(DOCUMENSO) + accounts Account[] + sessions Session[] + Document Document[] + Subscription Subscription? + PasswordResetToken PasswordResetToken[] + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + twoFactorBackupCodes String? + VerificationToken VerificationToken[] @@index([email]) } @@ -49,6 +53,16 @@ model PasswordResetToken { User User @relation(fields: [userId], references: [id]) } +model VerificationToken { + id Int @id @default(autoincrement()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) + userId Int + user User @relation(fields: [userId], references: [id]) +} + enum SubscriptionStatus { ACTIVE PAST_DUE @@ -120,6 +134,7 @@ model Document { documentMeta DocumentMeta? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt + completedAt DateTime? @@unique([documentDataId]) @@index([userId]) diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 6e728e387..d0671439f 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -5,6 +5,8 @@ "types": "./index.ts", "license": "MIT", "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", "clean": "rimraf node_modules" }, "dependencies": { @@ -17,5 +19,6 @@ "@trpc/server": "^10.36.0", "superjson": "^1.13.1", "zod": "^3.22.4" - } + }, + "devDependencies": {} } diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index f66f44325..59c51ade5 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -1,16 +1,23 @@ import { TRPCError } from '@trpc/server'; +import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { compareSync } from '@documenso/lib/server-only/auth/hash'; import { createUser } from '@documenso/lib/server-only/user/create-user'; +import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token'; -import { procedure, router } from '../trpc'; -import { ZSignUpMutationSchema } from './schema'; +import { authenticatedProcedure, procedure, router } from '../trpc'; +import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema'; export const authRouter = router({ signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => { try { const { name, email, password, signature } = input; - return await createUser({ name, email, password, signature }); + const user = await createUser({ name, email, password, signature }); + + await sendConfirmationToken({ email: user.email }); + + return user; } catch (err) { let message = 'We were unable to create your account. Please review the information you provided and try again.'; @@ -25,4 +32,23 @@ export const authRouter = router({ }); } }), + + verifyPassword: authenticatedProcedure + .input(ZVerifyPasswordMutationSchema) + .mutation(({ ctx, input }) => { + const user = ctx.user; + + const { password } = input; + + if (!user.password) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: ErrorCode.INCORRECT_PASSWORD, + }); + } + + const valid = compareSync(password, user.password); + + return valid; + }), }); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index bdc9cd742..cc969c679 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -8,3 +8,5 @@ export const ZSignUpMutationSchema = z.object({ }); export type TSignUpMutationSchema = z.infer; + +export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true }); diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index d8e165594..bd92312da 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -3,8 +3,10 @@ import { TRPCError } from '@trpc/server'; import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document'; +import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; +import { resendDocument } from '@documenso/lib/server-only/document/resend-document'; import { sendDocument } from '@documenso/lib/server-only/document/send-document'; import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; @@ -15,6 +17,7 @@ import { ZDeleteDraftDocumentMutationSchema, ZGetDocumentByIdQuerySchema, ZGetDocumentByTokenQuerySchema, + ZResendDocumentMutationSchema, ZSendDocumentMutationSchema, ZSetFieldsForDocumentMutationSchema, ZSetRecipientsForDocumentMutationSchema, @@ -172,4 +175,44 @@ export const documentRouter = router({ }); } }), + + resendDocument: authenticatedProcedure + .input(ZResendDocumentMutationSchema) + .mutation(async ({ input, ctx }) => { + try { + const { documentId, recipients } = input; + + return await resendDocument({ + userId: ctx.user.id, + documentId, + recipients, + }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to resend this document. Please try again later.', + }); + } + }), + + duplicateDocument: authenticatedProcedure + .input(ZGetDocumentByIdQuerySchema) + .mutation(async ({ input, ctx }) => { + try { + const { id } = input; + + return await duplicateDocumentById({ + id, + userId: ctx.user.id, + }); + } catch (err) { + console.log(err); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We are unable to duplicate this document. Please try again later.', + }); + } + }), }); diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts index e5b27c0ea..b9bea71c3 100644 --- a/packages/trpc/server/document-router/schema.ts +++ b/packages/trpc/server/document-router/schema.ts @@ -60,6 +60,11 @@ export const ZSendDocumentMutationSchema = z.object({ documentId: z.number(), }); +export const ZResendDocumentMutationSchema = z.object({ + documentId: z.number(), + recipients: z.array(z.number()).min(1), +}); + export type TSendDocumentMutationSchema = z.infer; export const ZDeleteDraftDocumentMutationSchema = z.object({ diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 0f6636650..4dcf4ca93 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -3,11 +3,13 @@ import { TRPCError } from '@trpc/server'; import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id'; import { resetPassword } from '@documenso/lib/server-only/user/reset-password'; +import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token'; import { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc'; import { + ZConfirmEmailMutationSchema, ZForgotPasswordFormSchema, ZResetPasswordFormSchema, ZRetrieveUserByIdQuerySchema, @@ -110,4 +112,25 @@ export const profileRouter = router({ }); } }), + + sendConfirmationEmail: procedure + .input(ZConfirmEmailMutationSchema) + .mutation(async ({ input }) => { + try { + const { email } = input; + + return sendConfirmationToken({ email }); + } catch (err) { + let message = 'We were unable to send a confirmation email. Please try again.'; + + if (err instanceof Error) { + message = err.message; + } + + throw new TRPCError({ + code: 'BAD_REQUEST', + message, + }); + } + }), }); diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 44a8a451c..ef9ca2a14 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -23,8 +23,13 @@ export const ZResetPasswordFormSchema = z.object({ token: z.string().min(1), }); +export const ZConfirmEmailMutationSchema = z.object({ + email: z.string().email().min(1), +}); + export type TRetrieveUserByIdQuerySchema = z.infer; export type TUpdateProfileMutationSchema = z.infer; export type TUpdatePasswordMutationSchema = z.infer; export type TForgotPasswordFormSchema = z.infer; export type TResetPasswordFormSchema = z.infer; +export type TConfirmEmailMutationSchema = z.infer; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index 519096da9..ab1ba0786 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -5,6 +5,7 @@ import { fieldRouter } from './field-router/router'; import { profileRouter } from './profile-router/router'; import { shareLinkRouter } from './share-link-router/router'; import { procedure, router } from './trpc'; +import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; export const appRouter = router({ health: procedure.query(() => { @@ -16,6 +17,7 @@ export const appRouter = router({ field: fieldRouter, admin: adminRouter, shareLink: shareLinkRouter, + twoFactorAuthentication: twoFactorAuthenticationRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/two-factor-authentication-router/router.ts b/packages/trpc/server/two-factor-authentication-router/router.ts new file mode 100644 index 000000000..a10f7a543 --- /dev/null +++ b/packages/trpc/server/two-factor-authentication-router/router.ts @@ -0,0 +1,105 @@ +import { TRPCError } from '@trpc/server'; + +import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa'; +import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa'; +import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code'; +import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; +import { compareSync } from '@documenso/lib/server-only/auth/hash'; + +import { authenticatedProcedure, router } from '../trpc'; +import { + ZDisableTwoFactorAuthenticationMutationSchema, + ZEnableTwoFactorAuthenticationMutationSchema, + ZSetupTwoFactorAuthenticationMutationSchema, + ZViewRecoveryCodesMutationSchema, +} from './schema'; + +export const twoFactorAuthenticationRouter = router({ + setup: authenticatedProcedure + .input(ZSetupTwoFactorAuthenticationMutationSchema) + .mutation(async ({ ctx, input }) => { + const user = ctx.user; + + const { password } = input; + + return await setupTwoFactorAuthentication({ user, password }); + }), + + enable: authenticatedProcedure + .input(ZEnableTwoFactorAuthenticationMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + const user = ctx.user; + + const { code } = input; + + return await enableTwoFactorAuthentication({ user, code }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to enable two-factor authentication. Please try again later.', + }); + } + }), + + disable: authenticatedProcedure + .input(ZDisableTwoFactorAuthenticationMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + const user = ctx.user; + + const { password, backupCode } = input; + + return await disableTwoFactorAuthentication({ user, password, backupCode }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to disable two-factor authentication. Please try again later.', + }); + } + }), + + viewRecoveryCodes: authenticatedProcedure + .input(ZViewRecoveryCodesMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + const user = ctx.user; + + const { password } = input; + + if (!user.twoFactorEnabled) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: ErrorCode.TWO_FACTOR_SETUP_REQUIRED, + }); + } + + if (!user.password || !compareSync(password, user.password)) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: ErrorCode.INCORRECT_PASSWORD, + }); + } + + const recoveryCodes = await getBackupCodes({ user }); + + return { recoveryCodes }; + } catch (err) { + console.error(err); + + if (err instanceof TRPCError) { + throw err; + } + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to view your recovery codes. Please try again later.', + }); + } + }), +}); diff --git a/packages/trpc/server/two-factor-authentication-router/schema.ts b/packages/trpc/server/two-factor-authentication-router/schema.ts new file mode 100644 index 000000000..3a831845f --- /dev/null +++ b/packages/trpc/server/two-factor-authentication-router/schema.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const ZSetupTwoFactorAuthenticationMutationSchema = z.object({ + password: z.string().min(1), +}); + +export type TSetupTwoFactorAuthenticationMutationSchema = z.infer< + typeof ZSetupTwoFactorAuthenticationMutationSchema +>; + +export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({ + code: z.string().min(6).max(6), +}); + +export type TEnableTwoFactorAuthenticationMutationSchema = z.infer< + typeof ZEnableTwoFactorAuthenticationMutationSchema +>; + +export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({ + password: z.string().min(6).max(72), + backupCode: z.string().trim(), +}); + +export type TDisableTwoFactorAuthenticationMutationSchema = z.infer< + typeof ZDisableTwoFactorAuthenticationMutationSchema +>; + +export const ZViewRecoveryCodesMutationSchema = z.object({ + password: z.string().min(6).max(72), +}); + +export type TViewRecoveryCodesMutationSchema = z.infer; diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 491b84012..c7cb370c9 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -7,6 +7,7 @@ declare namespace NodeJS { NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string; NEXT_PRIVATE_DATABASE_URL: string; + NEXT_PRIVATE_ENCRYPTION_KEY: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; diff --git a/packages/ui/components/document/document-share-button.tsx b/packages/ui/components/document/document-share-button.tsx index d15eb8874..c243fd87a 100644 --- a/packages/ui/components/document/document-share-button.tsx +++ b/packages/ui/components/document/document-share-button.tsx @@ -1,6 +1,6 @@ 'use client'; -import { HTMLAttributes, useState } from 'react'; +import React, { HTMLAttributes, useState } from 'react'; import { Copy, Share } from 'lucide-react'; import { FaXTwitter } from 'react-icons/fa6'; @@ -25,11 +25,17 @@ import { import { useToast } from '@documenso/ui/primitives/use-toast'; export type DocumentShareButtonProps = HTMLAttributes & { - token: string; + token?: string; documentId: number; + trigger?: (_props: { loading: boolean; disabled: boolean }) => React.ReactNode; }; -export const DocumentShareButton = ({ token, documentId, className }: DocumentShareButtonProps) => { +export const DocumentShareButton = ({ + token, + documentId, + className, + trigger, +}: DocumentShareButtonProps) => { const { toast } = useToast(); const { copyShareLink, createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({ @@ -81,6 +87,12 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh slug = result.slug; } + // Ensuring we've prewarmed the opengraph image for the Twitter + await fetch(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}/opengraph`, { + // We don't care about the response, so we can use no-cors + mode: 'no-cors', + }); + window.open( generateTwitterIntent( `I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`, @@ -94,16 +106,21 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh return ( - - + e.stopPropagation()} asChild> + {trigger?.({ + disabled: !token || !documentId, + loading: isLoading || isCopyingShareLink, + }) || ( + + )} @@ -126,6 +143,19 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh > {process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'} +
+ {shareLink?.slug && ( + sharing link + )} +
+
+ ); + }, +); + +PasswordInput.displayName = 'Input'; + +export { Input, PasswordInput }; diff --git a/packages/ui/primitives/pdf-viewer.tsx b/packages/ui/primitives/pdf-viewer.tsx index d057909d2..62b08d2f9 100644 --- a/packages/ui/primitives/pdf-viewer.tsx +++ b/packages/ui/primitives/pdf-viewer.tsx @@ -207,7 +207,7 @@ export const PDFViewer = ({ .map((_, i) => (
{ + const { theme, setTheme } = useTheme(); + const isMounted = useIsMounted(); + + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index f56344ed1..fe7bfa087 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -93,3 +93,24 @@ mask-composite: exclude; -webkit-mask-composite: xor; } + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; + background: transparent; + border-radius: 10px; + scrollbar-gutter: stable; +} + +.custom-scrollbar::-webkit-scrollbar-track { + border-radius: 10px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: rgb(100 116 139 / 1); + border-radius: 10px; + width: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgb(100 116 139 / 0.5); +} diff --git a/turbo.json b/turbo.json index 1692095b8..8b3e2fb93 100644 --- a/turbo.json +++ b/turbo.json @@ -5,7 +5,12 @@ "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**"] }, - "lint": {}, + "lint": { + "cache": false + }, + "lint:fix": { + "cache": false + }, "clean": { "cache": false }, @@ -28,6 +33,7 @@ "globalDependencies": ["**/.env.*local"], "globalEnv": [ "APP_VERSION", + "NEXT_PRIVATE_ENCRYPTION_KEY", "NEXTAUTH_URL", "NEXTAUTH_SECRET", "NEXT_PUBLIC_PROJECT", @@ -75,6 +81,7 @@ "NEXT_PRIVATE_SMTP_FROM_ADDRESS", "NEXT_PRIVATE_STRIPE_API_KEY", "NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET", + "NEXT_PRIVATE_GITHUB_TOKEN", "VERCEL", "VERCEL_ENV", "VERCEL_URL",