mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
Merge branch 'main' into feat/account-deletion
This commit is contained in:
@ -4,8 +4,10 @@ NEXTAUTH_SECRET="secret"
|
|||||||
|
|
||||||
# [[CRYPTO]]
|
# [[CRYPTO]]
|
||||||
# Application Key for symmetric encryption and decryption
|
# Application Key for symmetric encryption and decryption
|
||||||
# This should be a random string of at least 32 characters
|
# REQUIRED: This should be a random string of at least 32 characters
|
||||||
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||||
|
# REQUIRED: This should be a random string of at least 32 characters
|
||||||
|
NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||||
|
|
||||||
# [[AUTH OPTIONAL]]
|
# [[AUTH OPTIONAL]]
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
@ -23,7 +25,7 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/
|
|||||||
# [[E2E Tests]]
|
# [[E2E Tests]]
|
||||||
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
E2E_TEST_AUTHENTICATE_USERNAME="Test User"
|
||||||
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com"
|
||||||
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
|
||||||
|
|
||||||
# [[STORAGE]]
|
# [[STORAGE]]
|
||||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
@ -72,6 +74,8 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN=
|
|||||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR=
|
||||||
# OPTIONAL: The private key to use for DKIM signing.
|
# OPTIONAL: The private key to use for DKIM signing.
|
||||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
|
||||||
|
# OPTIONAL: Displays the maximum document upload limit to the user in MBs
|
||||||
|
NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT=5
|
||||||
|
|
||||||
# [[STRIPE]]
|
# [[STRIPE]]
|
||||||
NEXT_PRIVATE_STRIPE_API_KEY=
|
NEXT_PRIVATE_STRIPE_API_KEY=
|
||||||
|
|||||||
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@ -33,3 +33,4 @@ body:
|
|||||||
- label: I have explained the use case or scenario for this feature.
|
- label: I have explained the use case or scenario for this feature.
|
||||||
- label: I have included any relevant technical details or design suggestions.
|
- label: I have included any relevant technical details or design suggestions.
|
||||||
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
- label: I understand that this is a suggestion and that there is no guarantee of implementation.
|
||||||
|
- label: I want to work on creating a PR for this issue if approved
|
||||||
|
|||||||
87
apps/marketing/content/blog/commodifying-signing.mdx
Normal file
87
apps/marketing/content/blog/commodifying-signing.mdx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
title: Commodifying Signing
|
||||||
|
description: We are creating signing as a public good and are commoditizing it to make it cheaper and better.
|
||||||
|
authorName: 'Timur Ercan'
|
||||||
|
authorImage: '/blog/blog-author-timur.jpeg'
|
||||||
|
authorRole: 'Co-Founder'
|
||||||
|
date: 2024-01-25
|
||||||
|
Tags:
|
||||||
|
- Vision
|
||||||
|
- Mission
|
||||||
|
- Open Source
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<MdxNextImage
|
||||||
|
src="/blog/lighthouse.jpeg"
|
||||||
|
width="650"
|
||||||
|
height="650"
|
||||||
|
alt="A lighthouse on a tiny island."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<figcaption className="text-center">
|
||||||
|
Lighthouses are often used as an example of a public good; As they benefit all maritime users, but no one can be excluded from using them as a navigational aid. Use by one person neither prevents access by other people, nor does it reduce availability to others.
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
# Commodifying Signing
|
||||||
|
|
||||||
|
> TLDR; We are creating signing as a public good and are commoditizing it to make it cheaper and better.
|
||||||
|
|
||||||
|
While we are in full-on building mode with Documenso, I think a lot about the big picture of what we are attempting to do. One phrase that keeps popping up is, "We are commodifying signing." Let's dig deeper into what that means.
|
||||||
|
|
||||||
|
Let's start with why we are doing this. Documenso's mission is to solve the domain of signing once and for all for everyone. In so many calls, I hear stories about how organizations build their own solution because the existing ones are too expensive or need to be more flexible. That means not hundreds but probably thousands of companies worldwide have done the same. This is simply wasting humanity's time. Since digital signing systems are understood well enough that seemingly "everyone" can build them, given enough pain, It's time to do it once correctly.
|
||||||
|
|
||||||
|
## Is signing already a commodity?
|
||||||
|
|
||||||
|
> In economics, a **commodity** is an economic good, usually a resource, that has explicitly full or substantial fungibility: that is, the market treats instances of the good as equivalent or nearly so with no regard to who produced them.
|
||||||
|
|
||||||
|
That sounds like the signing market today. There is no shortage of signing providers, and you can get similar signing services from many places. So why is this different from what we want, and why does this not satisfy the market?
|
||||||
|
|
||||||
|
- Signing is expensive and painful when you are locked into your vendor, and they charge by signing volume.
|
||||||
|
- Signing is also expensive and painful when you have to build it yourself since no vendor fits your requirements or you are not allowed to
|
||||||
|
|
||||||
|
To understand why, we need to look at the landscape as it is today:
|
||||||
|
|
||||||
|
- **Commodity**: Signing SaaS
|
||||||
|
- **Private Goods**: Signing Code Base, Regulatory Know-How
|
||||||
|
- **Public Goods**: Web Tech, Digital Signature Algorithms and Standards
|
||||||
|
|
||||||
|
What the current players have done is to commodify the listed public goods into commercial products:
|
||||||
|
|
||||||
|
> […]the action and process of transforming goods, services, ideas, nature, personal information, people, or animals into commodities.
|
||||||
|
> (Let's ignore the end of that list for now and what it says about humanity, yikes)
|
||||||
|
|
||||||
|
While this paradigm brought digital signing to many businesses worldwide, we aim for a different future. To solve signing once and for all, we need to achieve two core points:
|
||||||
|
|
||||||
|
- Making it cheaper so it's profitable for everyone to use
|
||||||
|
- Making it more accessible so everyone can use it (e.g. regulated industries) and flexible enough (extendable, open).
|
||||||
|
|
||||||
|
To achieve this, we must transform the landscape to look like this:
|
||||||
|
|
||||||
|
- **Commodities**: Enterprise Components, Support, Hosting, Self-Host Licenses
|
||||||
|
- **Public Goods**: (no longer private): OS (Open Source) Signing Code Base, OS Regulatory Know-How
|
||||||
|
- **Public Goods**: OS Web Tech, Digital Signature Algorithms and Standards
|
||||||
|
|
||||||
|
## Raising the Bar
|
||||||
|
|
||||||
|
Before creating a commodity, we are raising the bar of what the underlying public good is. Having an open source singing framework you can extend, self-host, and understand makes the resulting solution much more accessible and extendable for everyone. Now for the final feat of making signing cheaper:
|
||||||
|
|
||||||
|
As we have seen, signing has already been commodified. But since it was done by a closed source and, frankly, a very opaque industry, no downward price spiral has ensued. By building Documenso open source with an open culture, we can pierce the veil and trigger what the space has been missing for a long time: Commoditization. If you had to read that again, so did I:
|
||||||
|
|
||||||
|
> In business literature, **commoditization** is defined as the process by which goods that have economic value and are distinguishable in terms of attributes (uniqueness or brand) become simple commodities in the eyes of the market or consumers.
|
||||||
|
|
||||||
|
By only selling what creates value for the customer (hosting a highly available service, keeping it compliant, supporting with technical issues and challenges, preparing industry-specific components), we are commoditizing signing since everyone can do it now: The resources enabling it are public goods, aka. open source. A leveled playing field, as described above, is the perfect environment for a community-first, technology-first, and value-first company like Documenso to flourish.
|
||||||
|
|
||||||
|
## Changing the Game
|
||||||
|
|
||||||
|
In this new world, a company needing signing (literally every company) can decide if the ROI (Return on Investment) of building signing themselves is greater than simply paying for the value-added activities they will need anyway. Pricing our offering not on volume but fixed is a nice additional wedge into the market we intend to use here.
|
||||||
|
|
||||||
|
The market dynamic now changes to who can offer the greatest value added to the public goods, driving the price down as this can be done much more efficiently than locking customers into closed source SaaS. Documenso, being a lean company, which we intend to stay with for a long time, will help kickstart this effect. Open Source capital efficiency is real. Our planned enterprise components, hosting support, and partner ecosystem will all leverage this effect.
|
||||||
|
|
||||||
|
We will grow our community around the public good, the open-source repo, and create an ecosystem around the commodities built on top of it (components, hosting, compliance, support). We will solve signing once and for all, and the world will be better for it. Onwards.
|
||||||
|
|
||||||
|
As always, feel free to connect on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments.
|
||||||
|
|
||||||
|
Best from Hamburg\
|
||||||
|
Timur
|
||||||
@ -7,6 +7,8 @@ authorRole: 'Co-Founder'
|
|||||||
date: 2023-07-13
|
date: 2023-07-13
|
||||||
tags:
|
tags:
|
||||||
- Manifesto
|
- Manifesto
|
||||||
|
- Open Source
|
||||||
|
- Vision
|
||||||
---
|
---
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
|
|||||||
BIN
apps/marketing/public/blog/lighthouse.jpeg
Normal file
BIN
apps/marketing/public/blog/lighthouse.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
@ -15,7 +15,7 @@ export const generateMetadata = ({ params }: { params: { content: string } }) =>
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { title: `Documenso - ${document.title}` };
|
return { title: document.title };
|
||||||
};
|
};
|
||||||
|
|
||||||
const mdxComponents: MDXComponents = {
|
const mdxComponents: MDXComponents = {
|
||||||
|
|||||||
@ -18,7 +18,9 @@ export const generateMetadata = ({ params }: { params: { post: string } }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `Documenso - ${blogPost.title}`,
|
title: {
|
||||||
|
absolute: `${blogPost.title} - Documenso Blog`,
|
||||||
|
},
|
||||||
description: blogPost.description,
|
description: blogPost.description,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { allBlogPosts } from 'contentlayer/generated';
|
import { allBlogPosts } from 'contentlayer/generated';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Blog',
|
||||||
|
};
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const blogPosts = allBlogPosts.sort((a, b) => {
|
const blogPosts = allBlogPosts.sort((a, b) => {
|
||||||
const dateA = new Date(a.date);
|
const dateA = new Date(a.date);
|
||||||
|
|||||||
@ -47,6 +47,14 @@ export const TEAM_MEMBERS = [
|
|||||||
engagement: 'Full-Time',
|
engagement: 'Full-Time',
|
||||||
joinDate: 'October 9th, 2023',
|
joinDate: 'October 9th, 2023',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Adithya Krishna',
|
||||||
|
role: 'Software Engineer - II',
|
||||||
|
salary: '-',
|
||||||
|
location: 'India',
|
||||||
|
engagement: 'Full-Time',
|
||||||
|
joinDate: 'December 1st, 2023',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FUNDING_RAISED = [
|
export const FUNDING_RAISED = [
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
import { getUserMonthlyGrowth } from '@documenso/lib/server-only/user/get-user-monthly-growth';
|
||||||
@ -14,6 +16,10 @@ import { MonthlyTotalUsersChart } from './monthly-total-users-chart';
|
|||||||
import { TeamMembers } from './team-members';
|
import { TeamMembers } from './team-members';
|
||||||
import { OpenPageTooltip } from './tooltip';
|
import { OpenPageTooltip } from './tooltip';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Open Startup',
|
||||||
|
};
|
||||||
|
|
||||||
export const revalidate = 3600;
|
export const revalidate = 3600;
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@ -5,7 +6,12 @@ import { z } from 'zod';
|
|||||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
|
|
||||||
import { OSSFriendsContainer } from './container';
|
import { OSSFriendsContainer } from './container';
|
||||||
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';
|
import type { TOSSFriendsSchema } from './schema';
|
||||||
|
import { ZOSSFriendsSchema } from './schema';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'OSS Friends',
|
||||||
|
};
|
||||||
|
|
||||||
export default async function OSSFriendsPage() {
|
export default async function OSSFriendsPage() {
|
||||||
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {
|
const ossFriends: TOSSFriendsSchema = await fetch('https://formbricks.com/api/oss-friends', {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
|
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
|
||||||
|
import type { Metadata } from 'next';
|
||||||
import { Caveat } from 'next/font/google';
|
import { Caveat } from 'next/font/google';
|
||||||
|
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -10,6 +11,11 @@ import { OpenBuildTemplateBento } from '~/components/(marketing)/open-build-temp
|
|||||||
import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento';
|
import { ShareConnectPaidWidgetBento } from '~/components/(marketing)/share-connect-paid-widget-bento';
|
||||||
|
|
||||||
export const revalidate = 600;
|
export const revalidate = 600;
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
absolute: 'Documenso - The Open Source DocuSign Alternative',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const fontCaveat = Caveat({
|
const fontCaveat = Caveat({
|
||||||
weight: ['500'],
|
weight: ['500'],
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -12,6 +11,10 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
|
|
||||||
import { PricingTable } from '~/components/(marketing)/pricing-table';
|
import { PricingTable } from '~/components/(marketing)/pricing-table';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Pricing',
|
||||||
|
};
|
||||||
|
|
||||||
export type PricingPageProps = {
|
export type PricingPageProps = {
|
||||||
searchParams?: {
|
searchParams?: {
|
||||||
planId?: string;
|
planId?: string;
|
||||||
|
|||||||
@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
|
|||||||
readStatus: 'OPENED',
|
readStatus: 'OPENED',
|
||||||
signingStatus: 'NOT_SIGNED',
|
signingStatus: 'NOT_SIGNED',
|
||||||
sendStatus: 'NOT_SENT',
|
sendStatus: 'NOT_SENT',
|
||||||
|
role: 'SIGNER',
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileDrop = async (file: File) => {
|
const onFileDrop = async (file: File) => {
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { SinglePlayerClient } from './client';
|
import { SinglePlayerClient } from './client';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Singleplayer',
|
||||||
|
};
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
// !: This entire file is a hack to get around failed prerendering of
|
// !: This entire file is a hack to get around failed prerendering of
|
||||||
|
|||||||
@ -18,7 +18,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
|||||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
title: {
|
||||||
|
template: '%s - Documenso',
|
||||||
|
default: 'Documenso',
|
||||||
|
},
|
||||||
description:
|
description:
|
||||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||||
keywords:
|
keywords:
|
||||||
|
|||||||
@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
|
disabled={isSubmitting}
|
||||||
className="aspect-video w-full rounded-md border"
|
className="aspect-video w-full rounded-md border"
|
||||||
defaultValue={signatureDataUrl || ''}
|
defaultValue={signatureDataUrl || ''}
|
||||||
onChange={setDraftSignatureDataUrl}
|
onChange={setDraftSignatureDataUrl}
|
||||||
|
|||||||
@ -45,6 +45,7 @@
|
|||||||
"sharp": "0.33.1",
|
"sharp": "0.33.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"ua-parser-js": "^1.0.37",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@ -53,7 +54,8 @@
|
|||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7",
|
||||||
|
"@types/ua-parser-js": "^0.7.39"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"next-auth": {
|
"next-auth": {
|
||||||
|
|||||||
@ -218,9 +218,9 @@ export const EditDocumentForm = ({
|
|||||||
<AddTitleFormPartial
|
<AddTitleFormPartial
|
||||||
key={recipients.length}
|
key={recipients.length}
|
||||||
documentFlow={documentFlow.title}
|
documentFlow={documentFlow.title}
|
||||||
|
document={document}
|
||||||
recipients={recipients}
|
recipients={recipients}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
document={document}
|
|
||||||
onSubmit={onAddTitleFormSubmit}
|
onSubmit={onAddTitleFormSubmit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Download, Edit, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
const role = recipient?.role;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
const onDownloadClick = async () => {
|
||||||
try {
|
try {
|
||||||
@ -68,6 +69,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||||
|
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isOwner,
|
isOwner,
|
||||||
isRecipient,
|
isRecipient,
|
||||||
@ -87,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
|||||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
{match(role)
|
||||||
|
.with(RecipientRole.SIGNER, () => (
|
||||||
|
<>
|
||||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.with(RecipientRole.APPROVER, () => (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
.otherwise(() => (
|
||||||
|
<>
|
||||||
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isPending: true, isSigned: true }, () => (
|
.with({ isPending: true, isSigned: true }, () => (
|
||||||
<Button className="w-32" disabled={true}>
|
<Button className="w-32" disabled={true}>
|
||||||
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
|
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||||
Sign
|
View
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
|
|||||||
@ -5,9 +5,11 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CheckCircle,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
|
EyeIcon,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
@ -19,7 +21,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
import type { Document, Recipient, User } from '@documenso/prisma/client';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
|
||||||
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
import { trpc as trpcClient } from '@documenso/trpc/client';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
@ -105,12 +107,32 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
|
|||||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{recipient?.role !== RecipientRole.CC && (
|
||||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||||
<Link href={`/sign/${recipient?.token}`}>
|
<Link href={`/sign/${recipient?.token}`}>
|
||||||
|
{recipient?.role === RecipientRole.VIEWER && (
|
||||||
|
<>
|
||||||
|
<EyeIcon className="mr-2 h-4 w-4" />
|
||||||
|
View
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipient?.role === RecipientRole.SIGNER && (
|
||||||
|
<>
|
||||||
<Pencil className="mr-2 h-4 w-4" />
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
Sign
|
Sign
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipient?.role === RecipientRole.APPROVER && (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Approve
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||||
<Link href={`/documents/${row.id}`}>
|
<Link href={`/documents/${row.id}`}>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
|
||||||
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
import { getStats } from '@documenso/lib/server-only/document/get-stats';
|
||||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||||
@ -8,7 +10,6 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||||
|
|
||||||
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
|
||||||
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
|
||||||
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
|
||||||
import { DocumentStatus } from '~/components/formatter/document-status';
|
import { DocumentStatus } from '~/components/formatter/document-status';
|
||||||
|
|
||||||
@ -25,18 +26,22 @@ export type DocumentsPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Documents',
|
||||||
|
};
|
||||||
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
const stats = await getStats({
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
|
||||||
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
|
||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
const perPage = Number(searchParams.perPage) || 20;
|
const perPage = Number(searchParams.perPage) || 20;
|
||||||
|
|
||||||
|
const stats = await getStats({
|
||||||
|
user,
|
||||||
|
period,
|
||||||
|
});
|
||||||
|
|
||||||
const results = await findDocuments({
|
const results = await findDocuments({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
status,
|
status,
|
||||||
@ -69,7 +74,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
|
|||||||
<h1 className="text-4xl font-semibold">Documents</h1>
|
<h1 className="text-4xl font-semibold">Documents</h1>
|
||||||
|
|
||||||
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
|
||||||
<Tabs defaultValue={status} className="overflow-x-auto">
|
<Tabs value={status} className="overflow-x-auto">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{[
|
{[
|
||||||
ExtendedDocumentStatus.INBOX,
|
ExtendedDocumentStatus.INBOX,
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
|
|
||||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
@ -96,6 +97,15 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onFileDropRejected = () => {
|
||||||
|
toast({
|
||||||
|
title: 'Your document failed to upload.',
|
||||||
|
description: `File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`,
|
||||||
|
duration: 5000,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<DocumentDropzone
|
<DocumentDropzone
|
||||||
@ -103,6 +113,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
|
|||||||
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
disabled={remaining.documents === 0 || !session?.user.emailVerified}
|
||||||
disabledMessage={disabledMessage}
|
disabledMessage={disabledMessage}
|
||||||
onDrop={onFileDrop}
|
onDrop={onFileDrop}
|
||||||
|
onDropRejected={onFileDropRejected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute -bottom-6 right-0">
|
<div className="absolute -bottom-6 right-0">
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -17,6 +18,10 @@ import { LocaleDate } from '~/components/formatter/locale-date';
|
|||||||
import { BillingPlans } from './billing-plans';
|
import { BillingPlans } from './billing-plans';
|
||||||
import { BillingPortalButton } from './billing-portal-button';
|
import { BillingPortalButton } from './billing-portal-button';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Billing',
|
||||||
|
};
|
||||||
|
|
||||||
export default async function BillingSettingsPage() {
|
export default async function BillingSettingsPage() {
|
||||||
let { user } = await getRequiredServerComponentSession();
|
let { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
|
||||||
import { ProfileForm } from '~/components/forms/profile';
|
import { ProfileForm } from '~/components/forms/profile';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Profile',
|
||||||
|
};
|
||||||
|
|
||||||
export default async function ProfileSettingsPage() {
|
export default async function ProfileSettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
import { UserSecurityActivityDataTable } from './user-security-activity-data-table';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Security activity',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsSecurityActivityPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">Security activity</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
View all recent security activity related to your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<UserSecurityActivityDataTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
import type { DateTimeFormatOptions } from 'luxon';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { UAParser } from 'ua-parser-js';
|
||||||
|
|
||||||
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
|
import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth';
|
||||||
|
import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
|
import { TableCell } from '@documenso/ui/primitives/table';
|
||||||
|
|
||||||
|
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||||
|
|
||||||
|
const dateFormat: DateTimeFormatOptions = {
|
||||||
|
...DateTime.DATETIME_SHORT,
|
||||||
|
hourCycle: 'h12',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserSecurityActivityDataTable = () => {
|
||||||
|
const parser = new UAParser();
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const parsedSearchParams = ZBaseTableSearchParamsSchema.parse(
|
||||||
|
Object.fromEntries(searchParams ?? []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isInitialLoading, isLoadingError } =
|
||||||
|
trpc.profile.findUserSecurityAuditLogs.useQuery(
|
||||||
|
{
|
||||||
|
page: parsedSearchParams.page,
|
||||||
|
perPage: parsedSearchParams.perPage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepPreviousData: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPaginationChange = (page: number, perPage: number) => {
|
||||||
|
updateSearchParams({
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = data ?? {
|
||||||
|
data: [],
|
||||||
|
perPage: 10,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
header: 'Date',
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
cell: ({ row }) => <LocaleDate format={dateFormat} date={row.original.createdAt} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Device',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.userAgent) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(row.original.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
let output = result.os.name;
|
||||||
|
|
||||||
|
if (!output) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.os.version) {
|
||||||
|
output += ` (${result.os.version})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Browser',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
if (!row.original.userAgent) {
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
parser.setUA(row.original.userAgent);
|
||||||
|
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
return result.browser.name ?? 'N/A';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'IP Address',
|
||||||
|
accessorKey: 'ipAddress',
|
||||||
|
cell: ({ row }) => row.original.ipAddress ?? 'N/A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Action',
|
||||||
|
accessorKey: 'type',
|
||||||
|
cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={results.data}
|
||||||
|
perPage={results.perPage}
|
||||||
|
currentPage={results.currentPage}
|
||||||
|
totalPages={results.totalPages}
|
||||||
|
onPaginationChange={onPaginationChange}
|
||||||
|
hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined}
|
||||||
|
onClearFilters={() => router.push(pathname ?? '/')}
|
||||||
|
error={{
|
||||||
|
enable: isLoadingError,
|
||||||
|
}}
|
||||||
|
skeleton={{
|
||||||
|
enable: isLoading && isInitialLoading,
|
||||||
|
rows: 3,
|
||||||
|
component: (
|
||||||
|
<>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-20 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Skeleton className="h-4 w-12 rounded-full" />
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(table) => <DataTablePagination table={table} />}
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,9 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth';
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app';
|
||||||
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes';
|
||||||
import { PasswordForm } from '~/components/forms/password';
|
import { PasswordForm } from '~/components/forms/password';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Security',
|
||||||
|
};
|
||||||
|
|
||||||
export default async function SecuritySettingsPage() {
|
export default async function SecuritySettingsPage() {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
|
|
||||||
@ -17,30 +27,76 @@ export default async function SecuritySettingsPage() {
|
|||||||
|
|
||||||
<hr className="my-4" />
|
<hr className="my-4" />
|
||||||
|
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
{user.identityProvider === 'DOCUMENSO' ? (
|
||||||
|
<div>
|
||||||
|
<PasswordForm user={user} />
|
||||||
|
|
||||||
<hr className="mb-4 mt-8" />
|
<hr className="border-border/50 mt-6" />
|
||||||
|
|
||||||
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Two factor authentication</AlertTitle>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<AlertDescription className="mr-4">
|
||||||
Add and manage your two factor security settings to add an extra layer of security to your
|
Create one-time passwords that serve as a secondary authentication method for
|
||||||
account!
|
confirming your identity when requested during the sign-in process.
|
||||||
</p>
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
<div className="mt-4 max-w-xl">
|
|
||||||
<h5 className="font-medium">Two-factor methods</h5>
|
|
||||||
|
|
||||||
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
</div>
|
</Alert>
|
||||||
|
|
||||||
{user.twoFactorEnabled && (
|
{user.twoFactorEnabled && (
|
||||||
<div className="mt-4 max-w-xl">
|
<Alert
|
||||||
<h5 className="font-medium">Recovery methods</h5>
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 sm:mb-0">
|
||||||
|
<AlertTitle>Recovery codes</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-4">
|
||||||
|
Two factor authentication recovery codes are used to access your account in the
|
||||||
|
event that you lose access to your authenticator app.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
</div>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Alert className="p-6" variant="neutral">
|
||||||
|
<AlertTitle>
|
||||||
|
Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]}
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
To update your password, enable two-factor authentication, and manage other security
|
||||||
|
settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account
|
||||||
|
settings.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||||
|
variant="neutral"
|
||||||
|
>
|
||||||
|
<div className="mb-4 mr-4 sm:mb-0">
|
||||||
|
<AlertTitle>Recent activity</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription className="mr-2">
|
||||||
|
View all recent security activity related to your account.
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/settings/security/activity">View activity</Link>
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { Loader, Plus } from 'lucide-react';
|
import { AlertTriangle, Loader, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import type { Template } from '@documenso/prisma/client';
|
import type { Template } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||||
@ -36,6 +39,8 @@ export const TemplatesDataTable = ({
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const updateSearchParams = useUpdateSearchParams();
|
const updateSearchParams = useUpdateSearchParams();
|
||||||
|
|
||||||
|
const { remaining } = useLimits();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -77,6 +82,19 @@ export const TemplatesDataTable = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
{remaining.documents === 0 && (
|
||||||
|
<Alert className="mb-4 mt-5">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Document Limit Exceeded!</AlertTitle>
|
||||||
|
<AlertDescription className="mt-2">
|
||||||
|
You have reached your document limit.{' '}
|
||||||
|
<Link className="underline underline-offset-4" href="/settings/billing">
|
||||||
|
Upgrade your account to continue!
|
||||||
|
</Link>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
@ -102,7 +120,7 @@ export const TemplatesDataTable = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
<Button
|
<Button
|
||||||
disabled={isRowLoading}
|
disabled={isRowLoading || remaining.documents === 0}
|
||||||
loading={isRowLoading}
|
loading={isRowLoading}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||||
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
|
||||||
|
|
||||||
@ -14,6 +16,10 @@ type TemplatesPageProps = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Templates',
|
||||||
|
};
|
||||||
|
|
||||||
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
|
||||||
const { user } = await getRequiredServerComponentSession();
|
const { user } = await getRequiredServerComponentSession();
|
||||||
const page = Number(searchParams.page) || 1;
|
const page = Number(searchParams.page) || 1;
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { FileSearch } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { DocumentData } from '@documenso/prisma/client';
|
||||||
|
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
||||||
|
import type { ButtonProps } from '@documenso/ui/primitives/button';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export type DocumentPreviewButtonProps = {
|
||||||
|
className?: string;
|
||||||
|
documentData: DocumentData;
|
||||||
|
} & ButtonProps;
|
||||||
|
|
||||||
|
export const DocumentPreviewButton = ({
|
||||||
|
className,
|
||||||
|
documentData,
|
||||||
|
...props
|
||||||
|
}: DocumentPreviewButtonProps) => {
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
className={className}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDialog((visible) => !visible)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
|
||||||
|
View Original Document
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DocumentDialog documentData={documentData} open={showDialog} onOpenChange={setShowDialog} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx
Normal file
17
apps/web/src/app/(signing)/sign/[token]/complete/layout.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||||
|
|
||||||
|
export type SigningLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SigningLayout({ children }: SigningLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<RefreshOnFocus />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,13 +10,15 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
|
|||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { DocumentStatus, FieldType } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
import { truncateTitle } from '~/helpers/truncate-title';
|
import { truncateTitle } from '~/helpers/truncate-title';
|
||||||
|
|
||||||
|
import { DocumentPreviewButton } from './document-preview-button';
|
||||||
|
|
||||||
export type CompletedSigningPageProps = {
|
export type CompletedSigningPageProps = {
|
||||||
params: {
|
params: {
|
||||||
token?: string;
|
token?: string;
|
||||||
@ -92,7 +94,10 @@ export default async function CompletedSigningPage({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
You have signed
|
You have
|
||||||
|
{recipient.role === RecipientRole.SIGNER && ' signed '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && ' viewed '}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && ' approved '}
|
||||||
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
<span className="mt-1.5 block">"{truncatedTitle}"</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@ -117,12 +122,20 @@ export default async function CompletedSigningPage({
|
|||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
|
{document.status === DocumentStatus.COMPLETED ? (
|
||||||
<DocumentDownloadButton
|
<DocumentDownloadButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
fileName={document.title}
|
fileName={document.title}
|
||||||
documentData={documentData}
|
documentData={documentData}
|
||||||
disabled={document.status !== DocumentStatus.COMPLETED}
|
disabled={document.status !== DocumentStatus.COMPLETED}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentPreviewButton
|
||||||
|
className="text-[11px]"
|
||||||
|
title="Signatures will appear once the document has been completed"
|
||||||
|
documentData={documentData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||||
import type { Document, Field, Recipient } from '@documenso/prisma/client';
|
import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -96,15 +96,52 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
|
|
||||||
<fieldset
|
<fieldset
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
|
<div className={cn('flex flex-1 flex-col')}>
|
||||||
|
<h3 className="text-foreground text-2xl font-semibold">
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'View Document'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{recipient.role === RecipientRole.VIEWER ? (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Please mark as viewed to complete
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="border-border mb-8 mt-4" />
|
||||||
|
|
||||||
|
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-y-4" />
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SignDialog
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onSignatureComplete={handleSubmit(onFormSubmit)}
|
||||||
|
document={document}
|
||||||
|
fields={fields}
|
||||||
|
fieldsValidated={fieldsValidated}
|
||||||
|
role={recipient.role}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<p className="text-muted-foreground mt-2 text-sm">
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
Please review the document before signing.
|
Please review the document before signing.
|
||||||
</p>
|
</p>
|
||||||
@ -132,6 +169,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
defaultValue={signature ?? undefined}
|
defaultValue={signature ?? undefined}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
setSignature(value);
|
setSignature(value);
|
||||||
@ -160,9 +198,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
|||||||
document={document}
|
document={document}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
fieldsValidated={fieldsValidated}
|
fieldsValidated={fieldsValidated}
|
||||||
|
role={recipient.role}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
|
|||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
|
||||||
@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
|||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-x-6">
|
<div className="mt-2.5 flex items-center gap-x-6">
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{document.User.name} ({document.User.email}) has invited you to sign this document.
|
{document.User.name} ({document.User.email}) has invited you to{' '}
|
||||||
|
{recipient.role === RecipientRole.VIEWER && 'view'}
|
||||||
|
{recipient.role === RecipientRole.SIGNER && 'sign'}
|
||||||
|
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { Document, Field } from '@documenso/prisma/client';
|
import type { Document, Field } from '@documenso/prisma/client';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -17,6 +18,7 @@ export type SignDialogProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
fieldsValidated: () => void | Promise<void>;
|
fieldsValidated: () => void | Promise<void>;
|
||||||
onSignatureComplete: () => void | Promise<void>;
|
onSignatureComplete: () => void | Promise<void>;
|
||||||
|
role: RecipientRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignDialog = ({
|
export const SignDialog = ({
|
||||||
@ -25,6 +27,7 @@ export const SignDialog = ({
|
|||||||
fields,
|
fields,
|
||||||
fieldsValidated,
|
fieldsValidated,
|
||||||
onSignatureComplete,
|
onSignatureComplete,
|
||||||
|
role,
|
||||||
}: SignDialogProps) => {
|
}: SignDialogProps) => {
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const truncatedTitle = truncateTitle(document.title);
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
@ -45,9 +48,18 @@ export const SignDialog = ({
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
|
<div className="text-foreground text-xl font-semibold">
|
||||||
|
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
|
||||||
|
{role === RecipientRole.SIGNER && 'Sign Document'}
|
||||||
|
{role === RecipientRole.APPROVER && 'Approve Document'}
|
||||||
|
</div>
|
||||||
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
|
||||||
You are about to finish signing "{truncatedTitle}". Are you sure?
|
{role === RecipientRole.VIEWER &&
|
||||||
|
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
|
||||||
|
{role === RecipientRole.SIGNER &&
|
||||||
|
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
|
||||||
|
{role === RecipientRole.APPROVER &&
|
||||||
|
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -71,7 +83,9 @@ export const SignDialog = ({
|
|||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
onClick={onSignatureComplete}
|
onClick={onSignatureComplete}
|
||||||
>
|
>
|
||||||
Sign
|
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
||||||
|
{role === RecipientRole.SIGNER && 'Sign'}
|
||||||
|
{role === RecipientRole.APPROVER && 'Approve'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Forgot password',
|
||||||
|
};
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
import { ForgotPasswordForm } from '~/components/forms/forgot-password';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Forgot Password',
|
||||||
|
};
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Reset Password',
|
||||||
|
};
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Sign In',
|
||||||
|
};
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -11,7 +18,7 @@ export default function SignInPage() {
|
|||||||
Welcome back, we are lucky to have you.
|
Welcome back, we are lucky to have you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignInForm className="mt-4" />
|
<SignInForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
{process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { IS_GOOGLE_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Sign Up',
|
||||||
|
};
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||||
redirect('/signin');
|
redirect('/signin');
|
||||||
@ -17,7 +24,7 @@ export default function SignUpPage() {
|
|||||||
signing is within your grasp.
|
signing is within your grasp.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<SignUpForm className="mt-4" />
|
<SignUpForm className="mt-4" isGoogleSSOEnabled={IS_GOOGLE_SSO_ENABLED} />
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { XCircle } from 'lucide-react';
|
import { XCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Verify Email',
|
||||||
|
};
|
||||||
|
|
||||||
export default function EmailVerificationWithoutTokenPage() {
|
export default function EmailVerificationWithoutTokenPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-start">
|
<div className="flex w-full items-start">
|
||||||
|
|||||||
@ -20,7 +20,10 @@ const fontInter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
|||||||
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
const fontCaveat = Caveat({ subsets: ['latin'], variable: '--font-signature' });
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Documenso - The Open Source DocuSign Alternative',
|
title: {
|
||||||
|
template: '%s - Documenso',
|
||||||
|
default: 'Documenso',
|
||||||
|
},
|
||||||
description:
|
description:
|
||||||
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
'Join Documenso, the open signing infrastructure, and get a 10x better signing experience. Pricing starts at $30/mo. forever! Sign in now and enjoy a faster, smarter, and more beautiful document signing process. Integrates with your favorite tools, customizable, and expandable. Support our mission and become a part of our open-source community.',
|
||||||
keywords:
|
keywords:
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
|
<div>
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
<div
|
||||||
|
className="text-muted-foreground text-sm"
|
||||||
|
title="Click to copy signing link for sending to recipient"
|
||||||
|
>
|
||||||
|
<p>{recipient.email} </p>
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import {
|
import {
|
||||||
@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
|
|||||||
type={getRecipientType(recipient)}
|
type={getRecipientType(recipient)}
|
||||||
fallbackText={recipientAbbreviation(recipient)}
|
fallbackText={recipientAbbreviation(recipient)}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
<div className="">
|
||||||
|
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||||
|
<p className="text-muted-foreground/70 text-xs">
|
||||||
|
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -252,7 +252,11 @@ const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
return THEMES.map((theme) => (
|
return THEMES.map((theme) => (
|
||||||
<CommandItem key={theme.theme} onSelect={() => setTheme(theme.theme)}>
|
<CommandItem
|
||||||
|
key={theme.theme}
|
||||||
|
onSelect={() => setTheme(theme.theme)}
|
||||||
|
className="mx-2 first:mt-2 last:mb-2"
|
||||||
|
>
|
||||||
<theme.icon className="mr-2" />
|
<theme.icon className="mr-2" />
|
||||||
{theme.label}
|
{theme.label}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[50] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[60] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
|
||||||
scrollY > 5 && 'border-b-border',
|
scrollY > 5 && 'border-b-border',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -68,7 +68,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
<DropdownMenuContent className="z-[60] w-56" align="end" forceMount>
|
||||||
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
<DropdownMenuLabel>Account</DropdownMenuLabel>
|
||||||
|
|
||||||
{isUserAdmin && (
|
{isUserAdmin && (
|
||||||
@ -122,7 +122,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
Themes
|
Themes
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent className="z-[60]">
|
||||||
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
||||||
<DropdownMenuRadioItem value="light">
|
<DropdownMenuRadioItem value="light">
|
||||||
<Sun className="mr-2 h-4 w-4" /> Light
|
<Sun className="mr-2 h-4 w-4" /> Light
|
||||||
@ -141,7 +141,11 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="https://github.com/documenso/documenso" className="cursor-pointer">
|
<Link
|
||||||
|
href="https://github.com/documenso/documenso"
|
||||||
|
className="cursor-pointer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
<LuGithub className="mr-2 h-4 w-4" />
|
<LuGithub className="mr-2 h-4 w-4" />
|
||||||
Star on Github
|
Star on Github
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
import { ONE_SECOND } from '@documenso/lib/constants/time';
|
import { ONE_DAY, ONE_SECOND } from '@documenso/lib/constants/time';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -65,7 +65,7 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
|
|||||||
if (emailVerificationDialogLastShown) {
|
if (emailVerificationDialogLastShown) {
|
||||||
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
|
||||||
|
|
||||||
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
|
if (Date.now() - lastShownTimestamp < ONE_DAY) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
|
||||||
|
|
||||||
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
|||||||
@ -19,28 +19,15 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
<div className="flex-shrink-0">
|
||||||
<div className="flex-1">
|
|
||||||
<p>Authenticator app</p>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
|
||||||
Create one-time passwords that serve as a secondary authentication method for confirming
|
|
||||||
your identity when requested during the sign-in process.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{isTwoFactorEnabled ? (
|
{isTwoFactorEnabled ? (
|
||||||
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
<Button variant="destructive" onClick={() => setModalState('disable')}>
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={() => setModalState('enable')} size="sm">
|
<Button onClick={() => setModalState('enable')}>Enable 2FA</Button>
|
||||||
Enable 2FA
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnableAuthenticatorAppDialog
|
<EnableAuthenticatorAppDialog
|
||||||
key={isEnableDialogOpen ? 'open' : 'closed'}
|
key={isEnableDialogOpen ? 'open' : 'closed'}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
@ -145,8 +146,8 @@ export const DisableAuthenticatorAppDialog = ({
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -157,7 +158,7 @@ export const DisableAuthenticatorAppDialog = ({
|
|||||||
>
|
>
|
||||||
Disable 2FA
|
Disable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
@ -190,15 +191,15 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { Button } from '@documenso/ui/primitives/button';
|
|||||||
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
|
||||||
|
|
||||||
type RecoveryCodesProps = {
|
type RecoveryCodesProps = {
|
||||||
// backupCodes: string[] | null;
|
|
||||||
isTwoFactorEnabled: boolean;
|
isTwoFactorEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -16,22 +15,13 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
<Button
|
||||||
<div className="flex-1">
|
className="flex-shrink-0"
|
||||||
<p>Recovery Codes</p>
|
onClick={() => setIsOpen(true)}
|
||||||
|
disabled={!isTwoFactorEnabled}
|
||||||
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
>
|
||||||
Recovery codes are used to access your account in the event that you lose access to your
|
|
||||||
authenticator app.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
|
||||||
View Codes
|
View Codes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ViewRecoveryCodesDialog
|
<ViewRecoveryCodesDialog
|
||||||
key={isOpen ? 'open' : 'closed'}
|
key={isOpen ? 'open' : 'closed'}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
@ -119,15 +120,15 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<DialogFooter>
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { z } from 'zod';
|
|||||||
import type { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -22,18 +23,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export const ZPasswordFormSchema = z
|
export const ZPasswordFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
currentPassword: z
|
currentPassword: ZCurrentPasswordSchema,
|
||||||
.string()
|
password: ZPasswordSchema,
|
||||||
.min(6, { message: 'Password should contain at least 6 characters' })
|
repeatedPassword: ZPasswordSchema,
|
||||||
.max(72, { message: 'Password should not contain more than 72 characters' }),
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.min(6, { message: 'Password should contain at least 6 characters' })
|
|
||||||
.max(72, { message: 'Password should not contain more than 72 characters' }),
|
|
||||||
repeatedPassword: z
|
|
||||||
.string()
|
|
||||||
.min(6, { message: 'Password should contain at least 6 characters' })
|
|
||||||
.max(72, { message: 'Password should not contain more than 72 characters' }),
|
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.repeatedPassword, {
|
.refine((data) => data.password === data.repeatedPassword, {
|
||||||
message: 'Passwords do not match',
|
message: 'Passwords do not match',
|
||||||
@ -145,7 +137,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
|
|||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="ml-auto mt-4">
|
||||||
<Button type="submit" loading={isSubmitting}>
|
<Button type="submit" loading={isSubmitting}>
|
||||||
{isSubmitting ? 'Updating password...' : 'Update password'}
|
{isSubmitting ? 'Updating password...' : 'Update password'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -197,7 +197,6 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="signature"
|
name="signature"
|
||||||
@ -207,7 +206,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-44 w-full"
|
className="h-44 w-full"
|
||||||
containerClassName="rounded-lg border bg-background"
|
disabled={isSubmitting}
|
||||||
|
containerClassName={cn('rounded-lg border bg-background')}
|
||||||
defaultValue={user.signature ?? undefined}
|
defaultValue={user.signature ?? undefined}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -23,8 +24,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
|
|
||||||
export const ZResetPasswordFormSchema = z
|
export const ZResetPasswordFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
password: z.string().min(6).max(72),
|
password: ZPasswordSchema,
|
||||||
repeatedPassword: z.string().min(6).max(72),
|
repeatedPassword: ZPasswordSchema,
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.repeatedPassword, {
|
.refine((data) => data.password === data.repeatedPassword, {
|
||||||
path: ['repeatedPassword'],
|
path: ['repeatedPassword'],
|
||||||
|
|||||||
@ -9,9 +9,16 @@ import { FcGoogle } from 'react-icons/fc';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
|
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -39,7 +46,7 @@ const LOGIN_REDIRECT_PATH = '/documents';
|
|||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z.string().min(6).max(72),
|
password: ZCurrentPasswordSchema,
|
||||||
totpCode: z.string().trim().optional(),
|
totpCode: z.string().trim().optional(),
|
||||||
backupCode: z.string().trim().optional(),
|
backupCode: z.string().trim().optional(),
|
||||||
});
|
});
|
||||||
@ -48,9 +55,10 @@ export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
|||||||
|
|
||||||
export type SignInFormProps = {
|
export type SignInFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isGoogleSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -109,7 +117,6 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
|
|
||||||
const result = await signIn('credentials', {
|
const result = await signIn('credentials', {
|
||||||
...credentials,
|
...credentials,
|
||||||
|
|
||||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
@ -203,6 +210,8 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isGoogleSSOEnabled && (
|
||||||
|
<>
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
<span className="text-muted-foreground bg-transparent">Or continue with</span>
|
||||||
@ -212,7 +221,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant={'outline'}
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithGoogleClick}
|
onClick={onSignInWithGoogleClick}
|
||||||
@ -220,7 +229,10 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isTwoFactorAuthenticationDialogOpen}
|
open={isTwoFactorAuthenticationDialogOpen}
|
||||||
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||||
@ -263,21 +275,23 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<DialogFooter className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="secondary"
|
||||||
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
||||||
>
|
>
|
||||||
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
|
{twoFactorAuthenticationMethod === 'totp'
|
||||||
|
? 'Use Backup Code'
|
||||||
|
: 'Use Authenticator'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" loading={isSubmitting}>
|
<Button type="submit" loading={isSubmitting}>
|
||||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -3,11 +3,13 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { FcGoogle } from 'react-icons/fc';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { TRPCClientError } from '@documenso/trpc/client';
|
import { TRPCClientError } from '@documenso/trpc/client';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -23,23 +25,33 @@ import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
|||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZSignUpFormSchema = z.object({
|
const SIGN_UP_REDIRECT_PATH = '/documents';
|
||||||
|
|
||||||
|
export const ZSignUpFormSchema = z
|
||||||
|
.object({
|
||||||
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
|
||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
password: z
|
password: ZPasswordSchema,
|
||||||
.string()
|
|
||||||
.min(6, { message: 'Password should contain at least 6 characters' })
|
|
||||||
.max(72, { message: 'Password should not contain more than 72 characters' }),
|
|
||||||
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
|
||||||
});
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const { name, email, password } = data;
|
||||||
|
return !password.includes(name) && !password.includes(email.split('@')[0]);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Password should not be common or based on personal information',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
|
||||||
|
|
||||||
export type SignUpFormProps = {
|
export type SignUpFormProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isGoogleSSOEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({ className }: SignUpFormProps) => {
|
export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
@ -64,7 +76,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
callbackUrl: '/',
|
callbackUrl: SIGN_UP_REDIRECT_PATH,
|
||||||
});
|
});
|
||||||
|
|
||||||
analytics.capture('App: User Sign Up', {
|
analytics.capture('App: User Sign Up', {
|
||||||
@ -89,6 +101,19 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignUpWithGoogleClick = async () => {
|
||||||
|
try {
|
||||||
|
await signIn('google', { callbackUrl: SIGN_UP_REDIRECT_PATH });
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: 'An unknown error occurred',
|
||||||
|
description:
|
||||||
|
'We encountered an unknown error while attempting to sign you Up. Please try again later.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@ -147,6 +172,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
className="h-36 w-full"
|
className="h-36 w-full"
|
||||||
|
disabled={isSubmitting}
|
||||||
containerClassName="mt-2 rounded-lg border bg-background"
|
containerClassName="mt-2 rounded-lg border bg-background"
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
@ -166,6 +192,28 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
|
|||||||
>
|
>
|
||||||
{isSubmitting ? 'Signing up...' : 'Sign Up'}
|
{isSubmitting ? 'Signing up...' : 'Sign Up'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{isGoogleSSOEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
<span className="text-muted-foreground bg-transparent">Or</span>
|
||||||
|
<div className="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant={'outline'}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignUpWithGoogleClick}
|
||||||
|
>
|
||||||
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
|
Sign Up with Google
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,17 +1,65 @@
|
|||||||
// import { NextApiRequest, NextApiResponse } from 'next';
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
import NextAuth from 'next-auth';
|
import NextAuth from 'next-auth';
|
||||||
|
|
||||||
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||||
|
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export default NextAuth({
|
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { ipAddress, userAgent } = extractNextApiRequestMetadata(req);
|
||||||
|
|
||||||
|
return await NextAuth(req, res, {
|
||||||
...NEXT_AUTH_OPTIONS,
|
...NEXT_AUTH_OPTIONS,
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/signin',
|
signIn: '/signin',
|
||||||
signOut: '/signout',
|
signOut: '/signout',
|
||||||
error: '/signin',
|
error: '/signin',
|
||||||
},
|
},
|
||||||
|
events: {
|
||||||
|
signIn: async ({ user }) => {
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_IN,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
signOut: async ({ token }) => {
|
||||||
|
const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;
|
||||||
|
|
||||||
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
|
if (isNaN(userId)) {
|
||||||
// res.json({ hello: 'world' });
|
return;
|
||||||
// }
|
}
|
||||||
|
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_OUT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
linkAccount: async ({ user }) => {
|
||||||
|
const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id;
|
||||||
|
|
||||||
|
if (isNaN(userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -39,6 +39,14 @@ ENV HUSKY 0
|
|||||||
ENV DOCKER_OUTPUT 1
|
ENV DOCKER_OUTPUT 1
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# Encryption keys
|
||||||
|
ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
|
||||||
|
ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
|
||||||
|
|
||||||
|
ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
||||||
|
ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
|
||||||
|
|
||||||
|
|
||||||
# Uncomment and use build args to enable remote caching
|
# Uncomment and use build args to enable remote caching
|
||||||
# ARG TURBO_TEAM
|
# ARG TURBO_TEAM
|
||||||
# ENV TURBO_TEAM=$TURBO_TEAM
|
# ENV TURBO_TEAM=$TURBO_TEAM
|
||||||
|
|||||||
@ -23,7 +23,8 @@ services:
|
|||||||
- database
|
- database
|
||||||
- inbucket
|
- inbucket
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
- NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
||||||
|
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
|
||||||
- NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
- NEXT_PUBLIC_WEBAPP_URL=http://localhost:3000
|
||||||
- NEXTAUTH_SECRET=my-super-secure-secret
|
- NEXTAUTH_SECRET=my-super-secure-secret
|
||||||
- NEXTAUTH_URL=http://localhost:3000
|
- NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -158,6 +158,7 @@
|
|||||||
"sharp": "0.33.1",
|
"sharp": "0.33.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"ua-parser-js": "^1.0.37",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
@ -166,7 +167,8 @@
|
|||||||
"@types/luxon": "^3.3.1",
|
"@types/luxon": "^3.3.1",
|
||||||
"@types/node": "20.1.0",
|
"@types/node": "20.1.0",
|
||||||
"@types/react": "18.2.18",
|
"@types/react": "18.2.18",
|
||||||
"@types/react-dom": "18.2.7"
|
"@types/react-dom": "18.2.7",
|
||||||
|
"@types/ua-parser-js": "^0.7.39"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apps/web/node_modules/@types/node": {
|
"apps/web/node_modules/@types/node": {
|
||||||
@ -6756,6 +6758,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
|
||||||
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A=="
|
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ua-parser-js": {
|
||||||
|
"version": "0.7.39",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz",
|
||||||
|
"integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/unist": {
|
"node_modules/@types/unist": {
|
||||||
"version": "2.0.10",
|
"version": "2.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
|
||||||
@ -18643,6 +18651,28 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ua-parser-js": {
|
||||||
|
"version": "1.0.37",
|
||||||
|
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
|
||||||
|
"integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ua-parser-js"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/faisalman"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/faisalman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
|
||||||
|
|||||||
@ -12,7 +12,7 @@ test.describe.configure({ mode: 'serial' });
|
|||||||
|
|
||||||
const username = 'Test User';
|
const username = 'Test User';
|
||||||
const email = 'test-user@auth-flow.documenso.com';
|
const email = 'test-user@auth-flow.documenso.com';
|
||||||
const password = 'Password123';
|
const password = 'Password123#';
|
||||||
|
|
||||||
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||||
await page.goto('/signup');
|
await page.goto('/signup');
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import type { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { Button, Section, Text } from '../components';
|
import { Button, Section, Text } from '../components';
|
||||||
import { TemplateDocumentImage } from './template-document-image';
|
import { TemplateDocumentImage } from './template-document-image';
|
||||||
|
|
||||||
@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
|
|||||||
documentName: string;
|
documentName: string;
|
||||||
signDocumentLink: string;
|
signDocumentLink: string;
|
||||||
assetBaseUrl: string;
|
assetBaseUrl: string;
|
||||||
|
role: RecipientRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateDocumentInvite = ({
|
export const TemplateDocumentInvite = ({
|
||||||
@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
|
|||||||
documentName,
|
documentName,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
|
role,
|
||||||
}: TemplateDocumentInviteProps) => {
|
}: TemplateDocumentInviteProps) => {
|
||||||
|
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||||
{inviterName} has invited you to sign
|
{inviterName} has invited you to {actionVerb.toLowerCase()}
|
||||||
<br />"{documentName}"
|
<br />"{documentName}"
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className="my-1 text-center text-base text-slate-400">
|
<Text className="my-1 text-center text-base text-slate-400">
|
||||||
Continue by signing the document.
|
Continue by {progressiveVerb.toLowerCase()} the document.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Section className="mb-6 mt-8 text-center">
|
<Section className="mb-6 mt-8 text-center">
|
||||||
@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
|
|||||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||||
href={signDocumentLink}
|
href={signDocumentLink}
|
||||||
>
|
>
|
||||||
Sign Document
|
{actionVerb} Document
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
|||||||
{isDocument && (
|
{isDocument && (
|
||||||
<Text className="my-4 text-base text-slate-400">
|
<Text className="my-4 text-base text-slate-400">
|
||||||
This document was sent using{' '}
|
This document was sent using{' '}
|
||||||
<Link className="text-[#7AC455]" href="https://documenso.com">
|
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
|
||||||
Documenso.
|
Documenso.
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||||
|
import type { RecipientRole } from '@documenso/prisma/client';
|
||||||
import config from '@documenso/tailwind-config';
|
import config from '@documenso/tailwind-config';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer';
|
|||||||
|
|
||||||
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
|
||||||
customBody?: string;
|
customBody?: string;
|
||||||
|
role: RecipientRole;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentInviteEmailTemplate = ({
|
export const DocumentInviteEmailTemplate = ({
|
||||||
@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
signDocumentLink = 'https://documenso.com',
|
signDocumentLink = 'https://documenso.com',
|
||||||
assetBaseUrl = 'http://localhost:3002',
|
assetBaseUrl = 'http://localhost:3002',
|
||||||
customBody,
|
customBody,
|
||||||
|
role,
|
||||||
}: DocumentInviteEmailTemplateProps) => {
|
}: DocumentInviteEmailTemplateProps) => {
|
||||||
const previewText = `${inviterName} has invited you to sign ${documentName}`;
|
const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
|
||||||
|
|
||||||
|
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
|
||||||
|
|
||||||
const getAssetUrl = (path: string) => {
|
const getAssetUrl = (path: string) => {
|
||||||
return new URL(path, assetBaseUrl).toString();
|
return new URL(path, assetBaseUrl).toString();
|
||||||
@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
documentName={documentName}
|
documentName={documentName}
|
||||||
signDocumentLink={signDocumentLink}
|
signDocumentLink={signDocumentLink}
|
||||||
assetBaseUrl={assetBaseUrl}
|
assetBaseUrl={assetBaseUrl}
|
||||||
|
role={role}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</Container>
|
</Container>
|
||||||
@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({
|
|||||||
{customBody ? (
|
{customBody ? (
|
||||||
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
<pre className="font-sans text-base text-slate-400">{customBody}</pre>
|
||||||
) : (
|
) : (
|
||||||
`${inviterName} has invited you to sign the document "${documentName}".`
|
`${inviterName} has invited you to ${action} the document "${documentName}".`
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import type { Recipient } from '@documenso/prisma/client';
|
import type { Recipient } from '@documenso/prisma/client';
|
||||||
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const getRecipientType = (recipient: Recipient) => {
|
export const getRecipientType = (recipient: Recipient) => {
|
||||||
if (
|
if (
|
||||||
recipient.sendStatus === SendStatus.SENT &&
|
recipient.role === RecipientRole.CC ||
|
||||||
recipient.signingStatus === SigningStatus.SIGNED
|
(recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
|
||||||
) {
|
) {
|
||||||
return 'completed';
|
return 'completed';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,3 +6,6 @@ export const APP_FOLDER = IS_APP_MARKETING ? 'marketing' : 'web';
|
|||||||
export const APP_BASE_URL = IS_APP_WEB
|
export const APP_BASE_URL = IS_APP_WEB
|
||||||
? process.env.NEXT_PUBLIC_WEBAPP_URL
|
? process.env.NEXT_PUBLIC_WEBAPP_URL
|
||||||
: process.env.NEXT_PUBLIC_MARKETING_URL;
|
: process.env.NEXT_PUBLIC_MARKETING_URL;
|
||||||
|
|
||||||
|
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT =
|
||||||
|
Number(process.env.NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT) || 50;
|
||||||
|
|||||||
@ -1 +1,25 @@
|
|||||||
|
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
export const SALT_ROUNDS = 12;
|
export const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
|
||||||
|
[IdentityProvider.DOCUMENSO]: 'Documenso',
|
||||||
|
[IdentityProvider.GOOGLE]: 'Google',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
||||||
|
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
|
||||||
|
[UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
|
||||||
|
[UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
|
||||||
|
[UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
|
||||||
|
[UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
|
||||||
|
[UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
|
||||||
|
[UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
|
||||||
|
[UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
|
||||||
|
[UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
|
||||||
|
[UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
|
||||||
|
[UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
|
||||||
|
};
|
||||||
|
|||||||
@ -1 +1,25 @@
|
|||||||
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||||
|
|
||||||
|
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
|
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
|
throw new Error(
|
||||||
|
'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
|
||||||
|
console.warn('*********************************************************************');
|
||||||
|
console.warn('*');
|
||||||
|
console.warn('*');
|
||||||
|
console.warn('Please change the encryption key from the default value of "CAFEBABE"');
|
||||||
|
console.warn('*');
|
||||||
|
console.warn('*');
|
||||||
|
console.warn('*********************************************************************');
|
||||||
|
}
|
||||||
|
|||||||
26
packages/lib/constants/recipient-roles.ts
Normal file
26
packages/lib/constants/recipient-roles.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export const RECIPIENT_ROLES_DESCRIPTION: {
|
||||||
|
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
|
||||||
|
} = {
|
||||||
|
[RecipientRole.APPROVER]: {
|
||||||
|
actionVerb: 'Approve',
|
||||||
|
progressiveVerb: 'Approving',
|
||||||
|
roleName: 'Approver',
|
||||||
|
},
|
||||||
|
[RecipientRole.CC]: {
|
||||||
|
actionVerb: 'CC',
|
||||||
|
progressiveVerb: 'CC',
|
||||||
|
roleName: 'CC',
|
||||||
|
},
|
||||||
|
[RecipientRole.SIGNER]: {
|
||||||
|
actionVerb: 'Sign',
|
||||||
|
progressiveVerb: 'Signing',
|
||||||
|
roleName: 'Signer',
|
||||||
|
},
|
||||||
|
[RecipientRole.VIEWER]: {
|
||||||
|
actionVerb: 'View',
|
||||||
|
progressiveVerb: 'Viewing',
|
||||||
|
roleName: 'Viewer',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -9,10 +9,12 @@ import type { GoogleProfile } from 'next-auth/providers/google';
|
|||||||
import GoogleProvider from 'next-auth/providers/google';
|
import GoogleProvider from 'next-auth/providers/google';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
|
import { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
|
||||||
import { ErrorCode } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||||
@ -34,7 +36,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
},
|
},
|
||||||
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||||
},
|
},
|
||||||
authorize: async (credentials, _req) => {
|
authorize: async (credentials, req) => {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||||
}
|
}
|
||||||
@ -50,8 +52,18 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordsSame = await compare(password, user.password);
|
const isPasswordsSame = await compare(password, user.password);
|
||||||
|
const requestMetadata = extractNextAuthRequestMetadata(req);
|
||||||
|
|
||||||
if (!isPasswordsSame) {
|
if (!isPasswordsSame) {
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: requestMetadata.ipAddress,
|
||||||
|
userAgent: requestMetadata.userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_IN_FAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,6 +73,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||||
|
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
|
await prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: requestMetadata.ipAddress,
|
||||||
|
userAgent: requestMetadata.userAgent,
|
||||||
|
type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
totpCode
|
totpCode
|
||||||
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||||
@ -93,7 +114,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user, trigger, account }) {
|
||||||
const merged = {
|
const merged = {
|
||||||
...token,
|
...token,
|
||||||
...user,
|
...user,
|
||||||
@ -138,6 +159,22 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
merged.emailVerified = user.emailVerified?.toISOString() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((trigger === 'signIn' || trigger === 'signUp') && account?.provider === 'google') {
|
||||||
|
merged.emailVerified = user?.emailVerified
|
||||||
|
? new Date(user.emailVerified).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: Number(merged.id),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
emailVerified: merged.emailVerified,
|
||||||
|
identityProvider: IdentityProvider.GOOGLE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: merged.id,
|
id: merged.id,
|
||||||
name: merged.name,
|
name: merged.name,
|
||||||
@ -175,4 +212,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
import { compare } from 'bcrypt';
|
import { compare } from 'bcrypt';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { ErrorCode } from '../../next-auth/error-codes';
|
import { ErrorCode } from '../../next-auth/error-codes';
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||||
|
|
||||||
type DisableTwoFactorAuthenticationOptions = {
|
type DisableTwoFactorAuthenticationOptions = {
|
||||||
user: User;
|
user: User;
|
||||||
backupCode: string;
|
backupCode: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const disableTwoFactorAuthentication = async ({
|
export const disableTwoFactorAuthentication = async ({
|
||||||
backupCode,
|
backupCode,
|
||||||
user,
|
user,
|
||||||
password,
|
password,
|
||||||
|
requestMetadata,
|
||||||
}: DisableTwoFactorAuthenticationOptions) => {
|
}: DisableTwoFactorAuthenticationOptions) => {
|
||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||||
@ -33,7 +37,8 @@ export const disableTwoFactorAuthentication = async ({
|
|||||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
@ -44,5 +49,15 @@ export const disableTwoFactorAuthentication = async ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,18 +1,21 @@
|
|||||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { User } from '@documenso/prisma/client';
|
import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { getBackupCodes } from './get-backup-code';
|
import { getBackupCodes } from './get-backup-code';
|
||||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||||
|
|
||||||
type EnableTwoFactorAuthenticationOptions = {
|
type EnableTwoFactorAuthenticationOptions = {
|
||||||
user: User;
|
user: User;
|
||||||
code: string;
|
code: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enableTwoFactorAuthentication = async ({
|
export const enableTwoFactorAuthentication = async ({
|
||||||
user,
|
user,
|
||||||
code,
|
code,
|
||||||
|
requestMetadata,
|
||||||
}: EnableTwoFactorAuthenticationOptions) => {
|
}: EnableTwoFactorAuthenticationOptions) => {
|
||||||
if (user.identityProvider !== 'DOCUMENSO') {
|
if (user.identityProvider !== 'DOCUMENSO') {
|
||||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||||
@ -32,7 +35,17 @@ export const enableTwoFactorAuthentication = async ({
|
|||||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
type: UserSecurityAuditLogType.AUTH_2FA_ENABLE,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
@ -40,6 +53,7 @@ export const enableTwoFactorAuthentication = async ({
|
|||||||
twoFactorEnabled: true,
|
twoFactorEnabled: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp';
|
|||||||
|
|
||||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { User } from '@documenso/prisma/client';
|
import { type User } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||||
import { symmetricEncrypt } from '../../universal/crypto';
|
import { symmetricEncrypt } from '../../universal/crypto';
|
||||||
|
|||||||
33
packages/lib/server-only/crypto/decrypt.ts
Normal file
33
packages/lib/server-only/crypto/decrypt.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||||
|
import { ZEncryptedDataSchema } from '@documenso/lib/server-only/crypto/encrypt';
|
||||||
|
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
|
||||||
|
*
|
||||||
|
* @param encryptedData The data encrypted with the `encryptSecondaryData` function.
|
||||||
|
* @returns The decrypted value, or `null` if the data is invalid or expired.
|
||||||
|
*/
|
||||||
|
export const decryptSecondaryData = (encryptedData: string): string | null => {
|
||||||
|
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
|
throw new Error('Missing encryption key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedBufferValue = symmetricDecrypt({
|
||||||
|
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||||
|
data: encryptedData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedValue = Buffer.from(decryptedBufferValue).toString('utf-8');
|
||||||
|
const result = ZEncryptedDataSchema.safeParse(JSON.parse(decryptedValue));
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data.expiresAt !== undefined && result.data.expiresAt < Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.data;
|
||||||
|
};
|
||||||
42
packages/lib/server-only/crypto/encrypt.ts
Normal file
42
packages/lib/server-only/crypto/encrypt.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { DOCUMENSO_ENCRYPTION_SECONDARY_KEY } from '@documenso/lib/constants/crypto';
|
||||||
|
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||||
|
import type { TEncryptSecondaryDataMutationSchema } from '@documenso/trpc/server/crypto/schema';
|
||||||
|
|
||||||
|
export const ZEncryptedDataSchema = z.object({
|
||||||
|
data: z.string(),
|
||||||
|
expiresAt: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EncryptDataOptions = {
|
||||||
|
data: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the data should no longer be allowed to be decrypted.
|
||||||
|
*
|
||||||
|
* Leave this empty to never expire the data.
|
||||||
|
*/
|
||||||
|
expiresAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt the passed in data. This uses the secondary encrypt key for miscellaneous data.
|
||||||
|
*
|
||||||
|
* @returns The encrypted data.
|
||||||
|
*/
|
||||||
|
export const encryptSecondaryData = ({ data, expiresAt }: TEncryptSecondaryDataMutationSchema) => {
|
||||||
|
if (!DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
|
||||||
|
throw new Error('Missing encryption key');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToEncrypt: z.infer<typeof ZEncryptedDataSchema> = {
|
||||||
|
data,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
return symmetricEncrypt({
|
||||||
|
key: DOCUMENSO_ENCRYPTION_SECONDARY_KEY,
|
||||||
|
data: JSON.stringify(dataToEncrypt),
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -3,12 +3,14 @@ import { P, match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { Document, Prisma } from '@documenso/prisma/client';
|
import type { Document, Prisma } from '@documenso/prisma/client';
|
||||||
import { SigningStatus } from '@documenso/prisma/client';
|
import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
import type { FindResultSet } from '../../types/find-result-set';
|
import type { FindResultSet } from '../../types/find-result-set';
|
||||||
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||||
|
|
||||||
|
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
|
||||||
|
|
||||||
export type FindDocumentsOptions = {
|
export type FindDocumentsOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
term?: string;
|
term?: string;
|
||||||
@ -19,7 +21,7 @@ export type FindDocumentsOptions = {
|
|||||||
column: keyof Omit<Document, 'document'>;
|
column: keyof Omit<Document, 'document'>;
|
||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
period?: '' | '7d' | '14d' | '30d';
|
period?: PeriodSelectorValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const findDocuments = async ({
|
export const findDocuments = async ({
|
||||||
@ -85,6 +87,9 @@ export const findDocuments = async ({
|
|||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.NOT_SIGNED,
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@ -107,6 +112,9 @@ export const findDocuments = async ({
|
|||||||
some: {
|
some: {
|
||||||
email: user.email,
|
email: user.email,
|
||||||
signingStatus: SigningStatus.SIGNED,
|
signingStatus: SigningStatus.SIGNED,
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
|
||||||
|
|
||||||
export interface GetDocumentAndSenderByTokenOptions {
|
export interface GetDocumentAndSenderByTokenOptions {
|
||||||
token: string;
|
token: string;
|
||||||
@ -58,7 +58,11 @@ export const getDocumentAndRecipientByToken = async ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
Recipient: true,
|
Recipient: {
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
documentData: true,
|
documentData: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,14 +1,31 @@
|
|||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { User } from '@documenso/prisma/client';
|
import type { Prisma, User } from '@documenso/prisma/client';
|
||||||
import { SigningStatus } from '@documenso/prisma/client';
|
import { SigningStatus } from '@documenso/prisma/client';
|
||||||
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
|
||||||
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
|
||||||
|
|
||||||
|
import type { PeriodSelectorValue } from './find-documents';
|
||||||
|
|
||||||
export type GetStatsInput = {
|
export type GetStatsInput = {
|
||||||
user: User;
|
user: User;
|
||||||
|
period?: PeriodSelectorValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStats = async ({ user }: GetStatsInput) => {
|
export const getStats = async ({ user, period }: GetStatsInput) => {
|
||||||
|
let createdAt: Prisma.DocumentWhereInput['createdAt'];
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||||
|
|
||||||
|
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||||
|
|
||||||
|
createdAt = {
|
||||||
|
gte: startOfPeriod.toJSDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
|
const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
|
||||||
prisma.document.groupBy({
|
prisma.document.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
@ -17,6 +34,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
createdAt,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -33,6 +51,7 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
|||||||
signingStatus: SigningStatus.NOT_SIGNED,
|
signingStatus: SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
createdAt,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -42,6 +61,12 @@ export const getStats = async ({ user }: GetStatsInput) => {
|
|||||||
_all: true,
|
_all: true,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
|
createdAt,
|
||||||
|
User: {
|
||||||
|
email: {
|
||||||
|
not: user.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
status: ExtendedDocumentStatus.PENDING,
|
status: ExtendedDocumentStatus.PENDING,
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
|||||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||||
|
|
||||||
export type ResendDocumentOptions = {
|
export type ResendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -59,6 +61,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
|
if (recipient.role === RecipientRole.CC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
@ -77,8 +83,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||||
|
role: recipient.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
@ -90,7 +99,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
|
|||||||
},
|
},
|
||||||
subject: customEmail?.subject
|
subject: customEmail?.subject
|
||||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
: 'Please sign this document',
|
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib';
|
|||||||
|
|
||||||
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
|
||||||
import { signPdf } from '@documenso/signing';
|
import { signPdf } from '@documenso/signing';
|
||||||
|
|
||||||
import { getFile } from '../../universal/upload/get-file';
|
import { getFile } from '../../universal/upload/get-file';
|
||||||
@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
|
|||||||
const recipients = await prisma.recipient.findMany({
|
const recipients = await prisma.recipient.findMany({
|
||||||
where: {
|
where: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
|
role: {
|
||||||
|
not: RecipientRole.CC,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
|
||||||
|
|
||||||
export type SearchDocumentsWithKeywordOptions = {
|
export type SearchDocumentsWithKeywordOptions = {
|
||||||
query: string;
|
query: string;
|
||||||
userId: number;
|
userId: number;
|
||||||
@ -77,5 +79,12 @@ export const searchDocumentsWithKeyword = async ({
|
|||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return documents;
|
const maskedDocuments = documents.map((document) =>
|
||||||
|
maskRecipientTokensForDocument({
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return maskedDocuments;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
|
|||||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
|
||||||
|
|
||||||
export type SendDocumentOptions = {
|
export type SendDocumentOptions = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -47,6 +49,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
document.Recipient.map(async (recipient) => {
|
document.Recipient.map(async (recipient) => {
|
||||||
|
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
@ -55,10 +61,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
'document.name': document.title,
|
'document.name': document.title,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (recipient.sendStatus === SendStatus.SENT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||||
|
|
||||||
@ -69,8 +71,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||||
|
role: recipient.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
@ -82,7 +87,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
|
|||||||
},
|
},
|
||||||
subject: customEmail?.subject
|
subject: customEmail?.subject
|
||||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
: 'Please sign this document',
|
: `Please ${actionVerb.toLowerCase()} this document`,
|
||||||
html: render(template),
|
html: render(template),
|
||||||
text: render(template, { plainText: true }),
|
text: render(template, { plainText: true }),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { nanoid } from '../../universal/id';
|
import { nanoid } from '../../universal/id';
|
||||||
@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions {
|
|||||||
id?: number | null;
|
id?: number | null;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
role: RecipientRole;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,13 +81,20 @@ export const setRecipientsForDocument = async ({
|
|||||||
update: {
|
update: {
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
documentId,
|
documentId,
|
||||||
|
signingStatus:
|
||||||
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
|
role: recipient.role,
|
||||||
token: nanoid(),
|
token: nanoid(),
|
||||||
documentId,
|
documentId,
|
||||||
|
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
|
||||||
|
signingStatus:
|
||||||
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { UserSecurityAuditLog, UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
export type FindUserSecurityAuditLogsOptions = {
|
||||||
|
userId: number;
|
||||||
|
type?: UserSecurityAuditLogType;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
|
orderBy?: {
|
||||||
|
column: keyof Omit<UserSecurityAuditLog, 'id' | 'userId'>;
|
||||||
|
direction: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findUserSecurityAuditLogs = async ({
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
page = 1,
|
||||||
|
perPage = 10,
|
||||||
|
orderBy,
|
||||||
|
}: FindUserSecurityAuditLogsOptions) => {
|
||||||
|
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||||
|
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [data, count] = await Promise.all([
|
||||||
|
prisma.userSecurityAuditLog.findMany({
|
||||||
|
where: whereClause,
|
||||||
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
|
take: perPage,
|
||||||
|
orderBy: {
|
||||||
|
[orderByColumn]: orderByDirection,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.userSecurityAuditLog.count({
|
||||||
|
where: whereClause,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
count,
|
||||||
|
currentPage: Math.max(page, 1),
|
||||||
|
perPage,
|
||||||
|
totalPages: Math.ceil(count / perPage),
|
||||||
|
} satisfies FindResultSet<typeof data>;
|
||||||
|
};
|
||||||
@ -1,16 +1,19 @@
|
|||||||
import { compare, hash } from 'bcrypt';
|
import { compare, hash } from 'bcrypt';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { sendResetPassword } from '../auth/send-reset-password';
|
import { sendResetPassword } from '../auth/send-reset-password';
|
||||||
|
|
||||||
export type ResetPasswordOptions = {
|
export type ResetPasswordOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetPassword = async ({ token, password }: ResetPasswordOptions) => {
|
export const resetPassword = async ({ token, password, requestMetadata }: ResetPasswordOptions) => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Invalid token provided. Please try again.');
|
throw new Error('Invalid token provided. Please try again.');
|
||||||
}
|
}
|
||||||
@ -56,6 +59,14 @@ export const resetPassword = async ({ token, password }: ResetPasswordOptions) =
|
|||||||
userId: foundToken.userId,
|
userId: foundToken.userId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
prisma.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: foundToken.userId,
|
||||||
|
type: UserSecurityAuditLogType.PASSWORD_RESET,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await sendResetPassword({ userId: foundToken.userId });
|
await sendResetPassword({ userId: foundToken.userId });
|
||||||
|
|||||||
@ -1,19 +1,22 @@
|
|||||||
import { compare, hash } from 'bcrypt';
|
import { compare, hash } from 'bcrypt';
|
||||||
|
|
||||||
|
import { SALT_ROUNDS } from '@documenso/lib/constants/auth';
|
||||||
|
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
|
||||||
|
|
||||||
export type UpdatePasswordOptions = {
|
export type UpdatePasswordOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
password: string;
|
password: string;
|
||||||
currentPassword: string;
|
currentPassword: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updatePassword = async ({
|
export const updatePassword = async ({
|
||||||
userId,
|
userId,
|
||||||
password,
|
password,
|
||||||
currentPassword,
|
currentPassword,
|
||||||
|
requestMetadata,
|
||||||
}: UpdatePasswordOptions) => {
|
}: UpdatePasswordOptions) => {
|
||||||
// Existence check
|
// Existence check
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@ -39,7 +42,17 @@ export const updatePassword = async ({
|
|||||||
|
|
||||||
const hashedNewPassword = await hash(password, SALT_ROUNDS);
|
const hashedNewPassword = await hash(password, SALT_ROUNDS);
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: UserSecurityAuditLogType.PASSWORD_UPDATE,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
@ -47,6 +60,5 @@ export const updatePassword = async ({
|
|||||||
password: hashedNewPassword,
|
password: hashedNewPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return updatedUser;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,21 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { UserSecurityAuditLogType } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
|
|
||||||
export type UpdateProfileOptions = {
|
export type UpdateProfileOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
name: string;
|
name: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateProfile = async ({ userId, name, signature }: UpdateProfileOptions) => {
|
export const updateProfile = async ({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
signature,
|
||||||
|
requestMetadata,
|
||||||
|
}: UpdateProfileOptions) => {
|
||||||
// Existence check
|
// Existence check
|
||||||
await prisma.user.findFirstOrThrow({
|
await prisma.user.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@ -14,7 +23,17 @@ export const updateProfile = async ({ userId, name, signature }: UpdateProfileOp
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userSecurityAuditLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE,
|
||||||
|
userAgent: requestMetadata?.userAgent,
|
||||||
|
ipAddress: requestMetadata?.ipAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await tx.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: userId,
|
id: userId,
|
||||||
},
|
},
|
||||||
@ -23,6 +42,5 @@ export const updateProfile = async ({ userId, name, signature }: UpdateProfileOp
|
|||||||
signature,
|
signature,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return updatedUser;
|
|
||||||
};
|
};
|
||||||
|
|||||||
20
packages/lib/types/search-params.ts
Normal file
20
packages/lib/types/search-params.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZBaseTableSearchParamsSchema = z.object({
|
||||||
|
query: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
page: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
perPage: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.catch(() => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TBaseTableSearchParamsSchema = z.infer<typeof ZBaseTableSearchParamsSchema>;
|
||||||
37
packages/lib/universal/extract-request-metadata.ts
Normal file
37
packages/lib/universal/extract-request-metadata.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import type { NextApiRequest } from 'next';
|
||||||
|
|
||||||
|
import type { RequestInternal } from 'next-auth';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const ZIpSchema = z.string().ip();
|
||||||
|
|
||||||
|
export type RequestMetadata = {
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => {
|
||||||
|
const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress);
|
||||||
|
|
||||||
|
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
|
||||||
|
const userAgent = req.headers['user-agent'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractNextAuthRequestMetadata = (
|
||||||
|
req: Pick<RequestInternal, 'body' | 'query' | 'headers' | 'method'>,
|
||||||
|
): RequestMetadata => {
|
||||||
|
const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']);
|
||||||
|
|
||||||
|
const ipAddress = parsedIp.success ? parsedIp.data : undefined;
|
||||||
|
const userAgent = req.headers?.['user-agent'];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RecipientRole" AS ENUM ('CC', 'SIGNER', 'VIEWER', 'APPROVER');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Recipient" ADD COLUMN "role" "RecipientRole" NOT NULL DEFAULT 'SIGNER';
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN', 'SIGN_IN_FAIL', 'SIGN_IN_2FA_FAIL');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "UserSecurityAuditLog" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"type" "UserSecurityAuditLogType" NOT NULL,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "UserSecurityAuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "UserSecurityAuditLog" ADD CONSTRAINT "UserSecurityAuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
UPDATE "User"
|
||||||
|
SET "emailVerified" = NOW()
|
||||||
|
FROM "Subscription"
|
||||||
|
WHERE "User"."id" = "Subscription"."userId"
|
||||||
|
AND "Subscription"."status" = 'ACTIVE'
|
||||||
|
AND "User"."emailVerified" IS NULL
|
||||||
@ -40,12 +40,38 @@ model User {
|
|||||||
twoFactorSecret String?
|
twoFactorSecret String?
|
||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
twoFactorBackupCodes String?
|
twoFactorBackupCodes String?
|
||||||
|
|
||||||
VerificationToken VerificationToken[]
|
VerificationToken VerificationToken[]
|
||||||
Template Template[]
|
Template Template[]
|
||||||
|
securityAuditLogs UserSecurityAuditLog[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserSecurityAuditLogType {
|
||||||
|
ACCOUNT_PROFILE_UPDATE
|
||||||
|
ACCOUNT_SSO_LINK
|
||||||
|
AUTH_2FA_DISABLE
|
||||||
|
AUTH_2FA_ENABLE
|
||||||
|
PASSWORD_RESET
|
||||||
|
PASSWORD_UPDATE
|
||||||
|
SIGN_OUT
|
||||||
|
SIGN_IN
|
||||||
|
SIGN_IN_FAIL
|
||||||
|
SIGN_IN_2FA_FAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserSecurityAuditLog {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
type UserSecurityAuditLogType
|
||||||
|
userAgent String?
|
||||||
|
ipAddress String?
|
||||||
|
|
||||||
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model PasswordResetToken {
|
model PasswordResetToken {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
token String @unique
|
token String @unique
|
||||||
@ -161,9 +187,9 @@ model DocumentMeta {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
subject String?
|
subject String?
|
||||||
message String?
|
message String?
|
||||||
timezone String? @db.Text @default("Etc/UTC")
|
timezone String? @default("Etc/UTC") @db.Text
|
||||||
password String?
|
password String?
|
||||||
dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a")
|
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||||
documentId Int @unique
|
documentId Int @unique
|
||||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
@ -183,6 +209,13 @@ enum SigningStatus {
|
|||||||
SIGNED
|
SIGNED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RecipientRole {
|
||||||
|
CC
|
||||||
|
SIGNER
|
||||||
|
VIEWER
|
||||||
|
APPROVER
|
||||||
|
}
|
||||||
|
|
||||||
model Recipient {
|
model Recipient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
documentId Int?
|
documentId Int?
|
||||||
@ -192,6 +225,7 @@ model Recipient {
|
|||||||
token String
|
token String
|
||||||
expired DateTime?
|
expired DateTime?
|
||||||
signedAt DateTime?
|
signedAt DateTime?
|
||||||
|
role RecipientRole @default(SIGNER)
|
||||||
readStatus ReadStatus @default(NOT_OPENED)
|
readStatus ReadStatus @default(NOT_OPENED)
|
||||||
signingStatus SigningStatus @default(NOT_SIGNED)
|
signingStatus SigningStatus @default(NOT_SIGNED)
|
||||||
sendStatus SendStatus @default(NOT_SENT)
|
sendStatus SendStatus @default(NOT_SENT)
|
||||||
|
|||||||
@ -1,9 +1,25 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZCurrentPasswordSchema = z
|
||||||
|
.string()
|
||||||
|
.min(6, { message: 'Must be at least 6 characters in length' })
|
||||||
|
.max(72);
|
||||||
|
|
||||||
|
export const ZPasswordSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(new RegExp('.*[A-Z].*'), { message: 'One uppercase character' })
|
||||||
|
.regex(new RegExp('.*[a-z].*'), { message: 'One lowercase character' })
|
||||||
|
.regex(new RegExp('.*\\d.*'), { message: 'One number' })
|
||||||
|
.regex(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), {
|
||||||
|
message: 'One special character is required',
|
||||||
|
})
|
||||||
|
.min(8, { message: 'Must be at least 8 characters in length' })
|
||||||
|
.max(72, { message: 'Cannot be more than 72 characters in length' });
|
||||||
|
|
||||||
export const ZSignUpMutationSchema = z.object({
|
export const ZSignUpMutationSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(6),
|
password: ZPasswordSchema,
|
||||||
signature: z.string().min(1, { message: 'A signature is required.' }),
|
signature: z.string().min(1, { message: 'A signature is required.' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
|
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
|
||||||
|
|
||||||
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
|
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
|
|||||||
return {
|
return {
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
req,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,12 +17,14 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
|
|||||||
return {
|
return {
|
||||||
session: null,
|
session: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
req,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
|
req,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
17
packages/trpc/server/crypto/router.ts
Normal file
17
packages/trpc/server/crypto/router.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt';
|
||||||
|
|
||||||
|
import { procedure, router } from '../trpc';
|
||||||
|
import { ZEncryptSecondaryDataMutationSchema } from './schema';
|
||||||
|
|
||||||
|
export const cryptoRouter = router({
|
||||||
|
encryptSecondaryData: procedure
|
||||||
|
.input(ZEncryptSecondaryDataMutationSchema)
|
||||||
|
.mutation(({ input }) => {
|
||||||
|
try {
|
||||||
|
return encryptSecondaryData(input);
|
||||||
|
} catch {
|
||||||
|
// Never leak errors for crypto.
|
||||||
|
throw new Error('Failed to encrypt data');
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user