Merge branch 'main' into refactor-forms

This commit is contained in:
nafees nazik
2023-12-02 17:24:06 +05:30
91 changed files with 4705 additions and 3645 deletions

View File

@@ -17,8 +17,8 @@
"@documenso/prisma": "*",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "14.0.0",
"next-auth": "4.24.3",
"next": "14.0.3",
"next-auth": "4.24.5",
"react": "18.2.0",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"

View File

@@ -0,0 +1,17 @@
export * from '@react-email/body';
export * from '@react-email/button';
export * from '@react-email/column';
export * from '@react-email/container';
export * from '@react-email/font';
export * from '@react-email/head';
export * from '@react-email/heading';
export * from '@react-email/hr';
export * from '@react-email/html';
export * from '@react-email/img';
export * from '@react-email/link';
export * from '@react-email/preview';
export * from '@react-email/render';
export * from '@react-email/row';
export * from '@react-email/section';
export * from '@react-email/tailwind';
export * from '@react-email/text';

View File

@@ -18,8 +18,23 @@
},
"dependencies": {
"@documenso/nodemailer-resend": "2.0.0",
"@react-email/components": "^0.0.11",
"@react-email/body": "0.0.4",
"@react-email/button": "0.0.11",
"@react-email/column": "0.0.8",
"@react-email/container": "0.0.10",
"@react-email/font": "0.0.4",
"@react-email/head": "0.0.6",
"@react-email/heading": "0.0.9",
"@react-email/hr": "0.0.6",
"@react-email/html": "0.0.6",
"@react-email/img": "0.0.6",
"@react-email/link": "0.0.6",
"@react-email/preview": "0.0.7",
"@react-email/render": "0.0.9",
"@react-email/row": "0.0.6",
"@react-email/section": "0.0.10",
"@react-email/tailwind": "0.0.9",
"@react-email/text": "0.0.6",
"nodemailer": "^6.9.3",
"react-email": "^1.9.5",
"resend": "^2.0.0"
@@ -29,8 +44,5 @@
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.8",
"tsup": "^7.1.0"
},
"overrides": {
"@react-email/tailwind": "0.0.9"
}
}

View File

@@ -1 +1 @@
export { render } from '@react-email/components';
export { render, renderAsync } from '@react-email/render';

View File

@@ -1,7 +1,4 @@
import { Button, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateConfirmationEmailProps = {
@@ -14,15 +11,7 @@ export const TemplateConfirmationEmail = ({
assetBaseUrl,
}: TemplateConfirmationEmailProps) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
@@ -47,6 +36,6 @@ export const TemplateConfirmationEmail = ({
</Text>
</Section>
</Section>
</Tailwind>
</>
);
};

View File

@@ -1,7 +1,4 @@
import { Button, Column, Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
import { Button, Column, Img, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentCompletedProps {
@@ -20,15 +17,7 @@ export const TemplateDocumentCompleted = ({
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
@@ -72,7 +61,7 @@ export const TemplateDocumentCompleted = ({
</Button>
</Section>
</Section>
</Tailwind>
</>
);
};

View File

@@ -1,4 +1,4 @@
import { Column, Img, Row, Section } from '@react-email/components';
import { Column, Img, Row, Section } from '../components';
export interface TemplateDocumentImageProps {
assetBaseUrl: string;

View File

@@ -1,7 +1,4 @@
import { Button, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentInviteProps {
@@ -19,15 +16,7 @@ export const TemplateDocumentInvite = ({
assetBaseUrl,
}: TemplateDocumentInviteProps) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
@@ -49,7 +38,7 @@ export const TemplateDocumentInvite = ({
</Button>
</Section>
</Section>
</Tailwind>
</>
);
};

View File

@@ -1,7 +1,4 @@
import { Column, Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
import { Column, Img, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentPendingProps {
@@ -18,15 +15,7 @@ export const TemplateDocumentPending = ({
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section>
@@ -52,7 +41,7 @@ export const TemplateDocumentPending = ({
We'll notify you as soon as it's ready.
</Text>
</Section>
</Tailwind>
</>
);
};

View File

@@ -1,7 +1,4 @@
import { Button, Column, Img, Link, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
import { Button, Column, Img, Link, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentSelfSignedProps {
@@ -20,15 +17,7 @@ export const TemplateDocumentSelfSigned = ({
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
@@ -84,7 +73,7 @@ export const TemplateDocumentSelfSigned = ({
</Button>
</Section>
</Section>
</Tailwind>
</>
);
};

View File

@@ -1,4 +1,4 @@
import { Link, Section, Text } from '@react-email/components';
import { Link, Section, Text } from '../components';
export type TemplateFooterProps = {
isDocument?: boolean;

View File

@@ -1,7 +1,4 @@
import { Button, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateForgotPasswordProps = {
@@ -14,15 +11,7 @@ export const TemplateForgotPassword = ({
assetBaseUrl,
}: TemplateForgotPasswordProps) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
@@ -43,7 +32,7 @@ export const TemplateForgotPassword = ({
</Button>
</Section>
</Section>
</Tailwind>
</>
);
};

View File

@@ -1,7 +1,4 @@
import { Button, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateResetPasswordProps {
@@ -12,15 +9,7 @@ export interface TemplateResetPasswordProps {
export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordProps) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
@@ -41,7 +30,7 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
</Button>
</Section>
</Section>
</Tailwind>
</>
);
};

View File

@@ -1,20 +1,8 @@
import {
Body,
Container,
Head,
Html,
Img,
Preview,
Section,
Tailwind,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import {
TemplateConfirmationEmail,
TemplateConfirmationEmailProps,
} from '../template-components/template-confirmation-email';
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
import type { TemplateConfirmationEmailProps } from '../template-components/template-confirmation-email';
import { TemplateConfirmationEmail } from '../template-components/template-confirmation-email';
import { TemplateFooter } from '../template-components/template-footer';
export const ConfirmEmailTemplate = ({

View File

@@ -1,20 +1,8 @@
import {
Body,
Container,
Head,
Html,
Img,
Preview,
Section,
Tailwind,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import {
TemplateDocumentCompleted,
TemplateDocumentCompletedProps,
} from '../template-components/template-document-completed';
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
import type { TemplateDocumentCompletedProps } from '../template-components/template-document-completed';
import { TemplateDocumentCompleted } from '../template-components/template-document-completed';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;

View File

@@ -1,3 +1,5 @@
import config from '@documenso/tailwind-config';
import {
Body,
Container,
@@ -10,14 +12,9 @@ import {
Section,
Tailwind,
Text,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import {
TemplateDocumentInvite,
TemplateDocumentInviteProps,
} from '../template-components/template-document-invite';
} from '../components';
import type { TemplateDocumentInviteProps } from '../template-components/template-document-invite';
import { TemplateDocumentInvite } from '../template-components/template-document-invite';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {

View File

@@ -1,20 +1,8 @@
import {
Body,
Container,
Head,
Html,
Img,
Preview,
Section,
Tailwind,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import {
TemplateDocumentPending,
TemplateDocumentPendingProps,
} from '../template-components/template-document-pending';
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
import type { TemplateDocumentPendingProps } from '../template-components/template-document-pending';
import { TemplateDocumentPending } from '../template-components/template-document-pending';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentPendingEmailTemplateProps = Partial<TemplateDocumentPendingProps>;

View File

@@ -1,20 +1,8 @@
import {
Body,
Container,
Head,
Html,
Img,
Preview,
Section,
Tailwind,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import {
TemplateDocumentSelfSigned,
TemplateDocumentSelfSignedProps,
} from '../template-components/template-document-self-signed';
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
import type { TemplateDocumentSelfSignedProps } from '../template-components/template-document-self-signed';
import { TemplateDocumentSelfSigned } from '../template-components/template-document-self-signed';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentSelfSignedTemplateProps = TemplateDocumentSelfSignedProps;

View File

@@ -1,21 +1,9 @@
import {
Body,
Container,
Head,
Html,
Img,
Preview,
Section,
Tailwind,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
import { Body, Container, Head, Html, Img, Preview, Section, Tailwind } from '../components';
import { TemplateFooter } from '../template-components/template-footer';
import {
TemplateForgotPassword,
TemplateForgotPasswordProps,
} from '../template-components/template-forgot-password';
import type { TemplateForgotPasswordProps } from '../template-components/template-forgot-password';
import { TemplateForgotPassword } from '../template-components/template-forgot-password';
export type ForgotPasswordTemplateProps = Partial<TemplateForgotPasswordProps>;

View File

@@ -1,3 +1,5 @@
import config from '@documenso/tailwind-config';
import {
Body,
Container,
@@ -10,15 +12,10 @@ import {
Section,
Tailwind,
Text,
} from '@react-email/components';
import config from '@documenso/tailwind-config';
} from '../components';
import { TemplateFooter } from '../template-components/template-footer';
import {
TemplateResetPassword,
TemplateResetPasswordProps,
} from '../template-components/template-reset-password';
import type { TemplateResetPasswordProps } from '../template-components/template-reset-password';
import { TemplateResetPassword } from '../template-components/template-reset-password';
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;

View File

@@ -0,0 +1 @@
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;

View File

@@ -10,6 +10,8 @@ import GoogleProvider from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma';
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { ErrorCode } from './error-codes';
@@ -25,13 +27,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
totpCode: {
label: 'Two-factor Code',
type: 'input',
placeholder: 'Code from authenticator app',
},
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
},
authorize: async (credentials, _req) => {
if (!credentials) {
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
}
const { email, password } = credentials;
const { email, password, backupCode, totpCode } = credentials;
const user = await getUserByEmail({ email }).catch(() => {
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
@@ -47,6 +55,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
}
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
if (is2faEnabled) {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
throw new Error(
totpCode
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
: ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE,
);
}
}
return {
id: Number(user.id),
email: user.email,

View File

@@ -8,4 +8,15 @@ export const ErrorCode = {
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
INTERNAL_SEVER_ERROR: 'INTERNAL_SEVER_ERROR',
TWO_FACTOR_ALREADY_ENABLED: 'TWO_FACTOR_ALREADY_ENABLED',
TWO_FACTOR_SETUP_REQUIRED: 'TWO_FACTOR_SETUP_REQUIRED',
TWO_FACTOR_MISSING_SECRET: 'TWO_FACTOR_MISSING_SECRET',
TWO_FACTOR_MISSING_CREDENTIALS: 'TWO_FACTOR_MISSING_CREDENTIALS',
INCORRECT_TWO_FACTOR_CODE: 'INCORRECT_TWO_FACTOR_CODE',
INCORRECT_TWO_FACTOR_BACKUP_CODE: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
INCORRECT_IDENTITY_PROVIDER: 'INCORRECT_IDENTITY_PROVIDER',
INCORRECT_PASSWORD: 'INCORRECT_PASSWORD',
MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY',
MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE',
} as const;

View File

@@ -25,6 +25,8 @@
"@documenso/prisma": "*",
"@documenso/signing": "*",
"@next-auth/prisma-adapter": "1.0.7",
"@noble/ciphers": "0.4.0",
"@noble/hashes": "1.3.2",
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
@@ -32,8 +34,9 @@
"bcrypt": "^5.1.0",
"luxon": "^3.4.0",
"nanoid": "^4.0.2",
"next": "14.0.0",
"next-auth": "4.24.3",
"next": "14.0.3",
"next-auth": "4.24.5",
"oslo": "^0.17.0",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
"remeda": "^1.27.1",

View File

@@ -0,0 +1,48 @@
import { compare } from 'bcrypt';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: User;
backupCode: string;
password: string;
};
export const disableTwoFactorAuthentication = async ({
backupCode,
user,
password,
}: DisableTwoFactorAuthenticationOptions) => {
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
if (!isValid) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
});
return true;
};

View File

@@ -0,0 +1,47 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
type EnableTwoFactorAuthenticationOptions = {
user: User;
code: string;
};
export const enableTwoFactorAuthentication = async ({
user,
code,
}: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (user.twoFactorEnabled) {
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
}
if (!user.twoFactorSecret) {
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
if (!isValidToken) {
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: true,
},
});
const recoveryCodes = getBackupCodes({ user: updatedUser });
return { recoveryCodes };
};

View File

@@ -0,0 +1,38 @@
import { z } from 'zod';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
interface GetBackupCodesOptions {
user: User;
}
const ZBackupCodeSchema = z.array(z.string());
export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!user.twoFactorEnabled) {
throw new Error('User has not enabled 2FA');
}
if (!user.twoFactorBackupCodes) {
throw new Error('User has no backup codes');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
'utf-8',
);
const data = JSON.parse(secret);
const result = ZBackupCodeSchema.safeParse(data);
if (result.success) {
return result.data;
}
return null;
};

View File

@@ -0,0 +1,17 @@
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
type IsTwoFactorAuthenticationEnabledOptions = {
user: User;
};
export const isTwoFactorAuthenticationEnabled = ({
user,
}: IsTwoFactorAuthenticationEnabledOptions) => {
return (
user.twoFactorEnabled &&
user.identityProvider === 'DOCUMENSO' &&
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
);
};

View File

@@ -0,0 +1,76 @@
import { base32 } from '@scure/base';
import { compare } from 'bcrypt';
import crypto from 'crypto';
import { createTOTPKeyURI } from 'oslo/otp';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto';
type SetupTwoFactorAuthenticationOptions = {
user: User;
password: string;
};
const ISSUER = 'Documenso';
export const setupTwoFactorAuthentication = async ({
user,
password,
}: SetupTwoFactorAuthenticationOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!key) {
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
}
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
}
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
}
const isCorrectPassword = await compare(password, user.password);
if (!isCorrectPassword) {
throw new Error(ErrorCode.INCORRECT_PASSWORD);
}
const secret = crypto.randomBytes(10);
const backupCodes = new Array(10)
.fill(null)
.map(() => crypto.randomBytes(5).toString('hex'))
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
const accountName = user.email;
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
const encodedSecret = base32.encode(secret);
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: symmetricEncrypt({
data: JSON.stringify(backupCodes),
key: key,
}),
twoFactorSecret: symmetricEncrypt({
data: encodedSecret,
key: key,
}),
},
});
return {
secret: encodedSecret,
uri,
};
};

View File

@@ -0,0 +1,35 @@
import { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
import { verifyBackupCode } from './verify-backup-code';
type ValidateTwoFactorAuthenticationOptions = {
totpCode?: string;
backupCode?: string;
user: User;
};
export const validateTwoFactorAuthentication = async ({
backupCode,
totpCode,
user,
}: ValidateTwoFactorAuthenticationOptions) => {
if (!user.twoFactorEnabled) {
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
if (!user.twoFactorSecret) {
throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET);
}
if (totpCode) {
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
}
if (backupCode) {
return await verifyBackupCode({ user, backupCode });
}
throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
};

View File

@@ -0,0 +1,33 @@
import { base32 } from '@scure/base';
import { TOTPController } from 'oslo/otp';
import { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto';
const totp = new TOTPController();
type VerifyTwoFactorAuthenticationTokenOptions = {
user: User;
totpCode: string;
};
export const verifyTwoFactorAuthenticationToken = async ({
user,
totpCode,
}: VerifyTwoFactorAuthenticationTokenOptions) => {
const key = DOCUMENSO_ENCRYPTION_KEY;
if (!user.twoFactorSecret) {
throw new Error('user missing 2fa secret');
}
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
'utf-8',
);
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
return isValidToken;
};

View File

@@ -0,0 +1,18 @@
import { User } from '@documenso/prisma/client';
import { getBackupCodes } from './get-backup-code';
type VerifyBackupCodeParams = {
user: User;
backupCode: string;
};
export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => {
const userBackupCodes = await getBackupCodes({ user });
if (!userBackupCodes) {
throw new Error('User has no backup codes');
}
return userBackupCodes.includes(backupCode);
};

View File

@@ -1,4 +1,4 @@
import { hashSync as bcryptHashSync } from 'bcrypt';
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
import { SALT_ROUNDS } from '../../constants/auth';
@@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth';
export const hashSync = (password: string) => {
return bcryptHashSync(password, SALT_ROUNDS);
};
export const compareSync = (password: string, hash: string) => {
return bcryptCompareSync(password, hash);
};

View File

@@ -57,7 +57,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
throw new Error('Can not send completed document');
}
await Promise.all([
await Promise.all(
document.Recipient.map(async (recipient) => {
const { email, name } = recipient;
@@ -95,5 +95,5 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
text: render(template, { plainText: true }),
});
}),
]);
);
};

View File

@@ -32,7 +32,7 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
const buffer = await getFile(document.documentData);
await Promise.all([
await Promise.all(
document.Recipient.map(async (recipient) => {
const { email, name, token } = recipient;
@@ -64,5 +64,5 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
],
});
}),
]);
);
};

View File

@@ -45,7 +45,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
throw new Error('Can not send completed document');
}
await Promise.all([
await Promise.all(
document.Recipient.map(async (recipient) => {
const { email, name } = recipient;
@@ -96,7 +96,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
},
});
}),
]);
);
const updatedDocument = await prisma.document.update({
where: {

View File

@@ -54,6 +54,7 @@ export const signFieldWithToken = async ({
field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE;
let customText = !isSignatureField ? value : undefined;
const signatureImageAsBase64 = isSignatureField && isBase64 ? value : undefined;
const typedSignature = isSignatureField && !isBase64 ? value : undefined;
@@ -61,29 +62,48 @@ export const signFieldWithToken = async ({
customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a');
}
await prisma.field.update({
where: {
id: field.id,
},
data: {
customText,
inserted: true,
Signature: isSignatureField
? {
upsert: {
create: {
recipientId: field.recipientId,
signatureImageAsBase64,
typedSignature,
},
update: {
recipientId: field.recipientId,
signatureImageAsBase64,
typedSignature,
},
},
}
: undefined,
},
if (isSignatureField && !signatureImageAsBase64 && !typedSignature) {
throw new Error('Signature field must have a signature');
}
return await prisma.$transaction(async (tx) => {
const updatedField = await tx.field.update({
where: {
id: field.id,
},
data: {
customText,
inserted: true,
},
});
if (isSignatureField) {
if (!field.recipientId) {
throw new Error('Field has no recipientId');
}
const signature = await tx.signature.upsert({
where: {
fieldId: field.id,
},
create: {
fieldId: field.id,
recipientId: field.recipientId,
signatureImageAsBase64: signatureImageAsBase64,
typedSignature: typedSignature,
},
update: {
signatureImageAsBase64: signatureImageAsBase64,
typedSignature: typedSignature,
},
});
// Dirty but I don't want to deal with type information
Object.assign(updatedField, {
Signature: signature,
});
}
return updatedField;
});
};

View File

@@ -0,0 +1,32 @@
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils';
import { managedNonce } from '@noble/ciphers/webcrypto/utils';
import { sha256 } from '@noble/hashes/sha256';
export type SymmetricEncryptOptions = {
key: string;
data: string;
};
export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => {
const keyAsBytes = sha256(key);
const dataAsBytes = utf8ToBytes(data);
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
return bytesToHex(chacha.encrypt(dataAsBytes));
};
export type SymmetricDecryptOptions = {
key: string;
data: string;
};
export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
const keyAsBytes = sha256(key);
const dataAsBytes = hexToBytes(data);
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
return chacha.decrypt(dataAsBytes);
};

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "twoFactorBackupCodes" TEXT,
ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "twoFactorSecret" TEXT;

View File

@@ -19,25 +19,28 @@ enum Role {
}
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
emailVerified DateTime?
password String?
source String?
signature String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
lastSignedIn DateTime @default(now())
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription?
PasswordResetToken PasswordResetToken[]
VerificationToken VerificationToken[]
id Int @id @default(autoincrement())
name String?
email String @unique
emailVerified DateTime?
password String?
source String?
signature String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
lastSignedIn DateTime @default(now())
roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[]
sessions Session[]
Document Document[]
Subscription Subscription?
PasswordResetToken PasswordResetToken[]
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
VerificationToken VerificationToken[]
@@index([email])
}

View File

@@ -9,7 +9,7 @@
"dependencies": {
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"tailwindcss": "^3.2.7",
"tailwindcss": "3.3.2",
"tailwindcss-animate": "^1.0.5"
},
"devDependencies": {

View File

@@ -21,5 +21,6 @@
"superjson": "^1.13.1",
"ts-pattern": "^5.0.5",
"zod": "^3.22.4"
}
},
"devDependencies": {}
}

View File

@@ -1,10 +1,12 @@
import { TRPCError } from '@trpc/server';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { createUser } from '@documenso/lib/server-only/user/create-user';
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
import { procedure, router } from '../trpc';
import { ZSignUpMutationSchema } from './schema';
import { authenticatedProcedure, procedure, router } from '../trpc';
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
export const authRouter = router({
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
@@ -30,4 +32,23 @@ export const authRouter = router({
});
}
}),
verifyPassword: authenticatedProcedure
.input(ZVerifyPasswordMutationSchema)
.mutation(({ ctx, input }) => {
const user = ctx.user;
const { password } = input;
if (!user.password) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: ErrorCode.INCORRECT_PASSWORD,
});
}
const valid = compareSync(password, user.password);
return valid;
}),
});

View File

@@ -8,3 +8,5 @@ export const ZSignUpMutationSchema = z.object({
});
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });

View File

@@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
@@ -119,7 +120,7 @@ export const documentRouter = router({
.input(ZSetTitleForDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
const { documentId, title } = input;
const userId = ctx.user.id;
return await updateTitle({
@@ -176,7 +177,15 @@ export const documentRouter = router({
.input(ZSendDocumentMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId } = input;
const { documentId, email } = input;
if (email.message || email.subject) {
await upsertDocumentMeta({
documentId,
subject: email.subject,
message: email.message,
});
}
return await sendDocument({
userId: ctx.user.id,

View File

@@ -65,6 +65,10 @@ export type TSetFieldsForDocumentMutationSchema = z.infer<
export const ZSendDocumentMutationSchema = z.object({
documentId: z.number(),
email: z.object({
subject: z.string(),
message: z.string(),
}),
});
export const ZResendDocumentMutationSchema = z.object({

View File

@@ -1,15 +1,47 @@
import { TRPCError } from '@trpc/server';
import { removeSignedFieldWithToken } from '@documenso/lib/server-only/field/remove-signed-field-with-token';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { procedure, router } from '../trpc';
import { authenticatedProcedure, procedure, router } from '../trpc';
import {
ZAddFieldsMutationSchema,
ZRemovedSignedFieldWithTokenMutationSchema,
ZSignFieldWithTokenMutationSchema,
} from './schema';
export const fieldRouter = router({
addFields: authenticatedProcedure
.input(ZAddFieldsMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, fields } = input;
return await setFieldsForDocument({
documentId,
userId: ctx.user.id,
fields: fields.map((field) => ({
id: field.nativeId,
signerEmail: field.signerEmail,
type: field.type,
pageNumber: field.pageNumber,
pageX: field.pageX,
pageY: field.pageY,
pageWidth: field.pageWidth,
pageHeight: field.pageHeight,
})),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
signFieldWithToken: procedure
.input(ZSignFieldWithTokenMutationSchema)
.mutation(async ({ input }) => {

View File

@@ -1,5 +1,26 @@
import { z } from 'zod';
import { FieldType } from '@documenso/prisma/client';
export const ZAddFieldsMutationSchema = z.object({
documentId: z.number(),
fields: z.array(
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
type: z.nativeEnum(FieldType),
signerEmail: z.string().min(1),
pageNumber: z.number().min(1),
pageX: z.number().min(0),
pageY: z.number().min(0),
pageWidth: z.number().min(0),
pageHeight: z.number().min(0),
}),
),
});
export type TAddFieldsMutationSchema = z.infer<typeof ZAddFieldsMutationSchema>;
export const ZSignFieldWithTokenMutationSchema = z.object({
token: z.string(),
fieldId: z.number(),

View File

@@ -0,0 +1,54 @@
import { TRPCError } from '@trpc/server';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { authenticatedProcedure, procedure, router } from '../trpc';
import { ZAddSignersMutationSchema, ZCompleteDocumentWithTokenMutationSchema } from './schema';
export const recipientRouter = router({
addSigners: authenticatedProcedure
.input(ZAddSignersMutationSchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, signers } = input;
return await setRecipientsForDocument({
userId: ctx.user.id,
documentId,
recipients: signers.map((signer) => ({
id: signer.nativeId,
email: signer.email,
name: signer.name,
})),
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
completeDocumentWithToken: procedure
.input(ZCompleteDocumentWithTokenMutationSchema)
.mutation(async ({ input }) => {
try {
const { token, documentId } = input;
return await completeDocumentWithToken({
token,
documentId,
});
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to sign this field. Please try again later.',
});
}
}),
});

View File

@@ -0,0 +1,33 @@
import { z } from 'zod';
export const ZAddSignersMutationSchema = z
.object({
documentId: z.number(),
signers: z.array(
z.object({
nativeId: z.number().optional(),
email: z.string().email().min(1),
name: z.string(),
}),
),
})
.refine(
(schema) => {
const emails = schema.signers.map((signer) => signer.email.toLowerCase());
return new Set(emails).size === emails.length;
},
// Dirty hack to handle errors when .root is populated for an array type
{ message: 'Signers must have unique emails', path: ['signers__root'] },
);
export type TAddSignersMutationSchema = z.infer<typeof ZAddSignersMutationSchema>;
export const ZCompleteDocumentWithTokenMutationSchema = z.object({
token: z.string(),
documentId: z.number(),
});
export type TCompleteDocumentWithTokenMutationSchema = z.infer<
typeof ZCompleteDocumentWithTokenMutationSchema
>;

View File

@@ -3,18 +3,22 @@ import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router';
import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
import { shareLinkRouter } from './share-link-router/router';
import { singleplayerRouter } from './singleplayer-router/router';
import { router } from './trpc';
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
export const appRouter = router({
auth: authRouter,
profile: profileRouter,
document: documentRouter,
field: fieldRouter,
recipient: recipientRouter,
admin: adminRouter,
shareLink: shareLinkRouter,
singleplayer: singleplayerRouter,
twoFactorAuthentication: twoFactorAuthenticationRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -3,7 +3,7 @@ import { createElement } from 'react';
import { PDFDocument } from 'pdf-lib';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { renderAsync } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
@@ -36,6 +36,7 @@ export const singleplayerRouter = router({
});
const doc = await PDFDocument.load(document);
const createdAt = new Date();
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
@@ -149,6 +150,11 @@ export const singleplayerRouter = router({
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
});
const [html, text] = await Promise.all([
renderAsync(template),
renderAsync(template, { plainText: true }),
]);
// Send email to signer.
await mailer.sendMail({
to: {
@@ -160,8 +166,8 @@ export const singleplayerRouter = router({
address: FROM_ADDRESS,
},
subject: 'Document signed',
html: render(template),
text: render(template, { plainText: true }),
html,
text,
attachments: [{ content: signedPdfBuffer, filename: documentName }],
});

View File

@@ -0,0 +1,105 @@
import { TRPCError } from '@trpc/server';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { compareSync } from '@documenso/lib/server-only/auth/hash';
import { authenticatedProcedure, router } from '../trpc';
import {
ZDisableTwoFactorAuthenticationMutationSchema,
ZEnableTwoFactorAuthenticationMutationSchema,
ZSetupTwoFactorAuthenticationMutationSchema,
ZViewRecoveryCodesMutationSchema,
} from './schema';
export const twoFactorAuthenticationRouter = router({
setup: authenticatedProcedure
.input(ZSetupTwoFactorAuthenticationMutationSchema)
.mutation(async ({ ctx, input }) => {
const user = ctx.user;
const { password } = input;
return await setupTwoFactorAuthentication({ user, password });
}),
enable: authenticatedProcedure
.input(ZEnableTwoFactorAuthenticationMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const user = ctx.user;
const { code } = input;
return await enableTwoFactorAuthentication({ user, code });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to enable two-factor authentication. Please try again later.',
});
}
}),
disable: authenticatedProcedure
.input(ZDisableTwoFactorAuthenticationMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const user = ctx.user;
const { password, backupCode } = input;
return await disableTwoFactorAuthentication({ user, password, backupCode });
} catch (err) {
console.error(err);
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to disable two-factor authentication. Please try again later.',
});
}
}),
viewRecoveryCodes: authenticatedProcedure
.input(ZViewRecoveryCodesMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const user = ctx.user;
const { password } = input;
if (!user.twoFactorEnabled) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: ErrorCode.TWO_FACTOR_SETUP_REQUIRED,
});
}
if (!user.password || !compareSync(password, user.password)) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: ErrorCode.INCORRECT_PASSWORD,
});
}
const recoveryCodes = await getBackupCodes({ user });
return { recoveryCodes };
} catch (err) {
console.error(err);
if (err instanceof TRPCError) {
throw err;
}
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to view your recovery codes. Please try again later.',
});
}
}),
});

View File

@@ -0,0 +1,32 @@
import { z } from 'zod';
export const ZSetupTwoFactorAuthenticationMutationSchema = z.object({
password: z.string().min(1),
});
export type TSetupTwoFactorAuthenticationMutationSchema = z.infer<
typeof ZSetupTwoFactorAuthenticationMutationSchema
>;
export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({
code: z.string().min(6).max(6),
});
export type TEnableTwoFactorAuthenticationMutationSchema = z.infer<
typeof ZEnableTwoFactorAuthenticationMutationSchema
>;
export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({
password: z.string().min(6).max(72),
backupCode: z.string().trim(),
});
export type TDisableTwoFactorAuthenticationMutationSchema = z.infer<
typeof ZDisableTwoFactorAuthenticationMutationSchema
>;
export const ZViewRecoveryCodesMutationSchema = z.object({
password: z.string().min(6).max(72),
});
export type TViewRecoveryCodesMutationSchema = z.infer<typeof ZViewRecoveryCodesMutationSchema>;

View File

@@ -7,6 +7,7 @@ declare namespace NodeJS {
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
NEXT_PRIVATE_DATABASE_URL: string;
NEXT_PRIVATE_ENCRYPTION_KEY: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;

View File

@@ -62,7 +62,7 @@
"framer-motion": "^10.12.8",
"lucide-react": "^0.279.0",
"luxon": "^3.4.2",
"next": "14.0.0",
"next": "14.0.3",
"pdfjs-dist": "3.6.172",
"react-day-picker": "^8.7.1",
"react-hook-form": "^7.45.4",

View File

@@ -1,6 +1,9 @@
import * as React from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { cn } from '../lib/utils';
import { Button } from './button';
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
@@ -25,4 +28,38 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
Input.displayName = 'Input';
export { Input };
const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
className={cn('pr-10', className)}
ref={ref}
{...props}
/>
<Button
variant="link"
type="button"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? (
<EyeOff aria-hidden className="text-muted-foreground h-5 w-5" />
) : (
<Eye aria-hidden className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
);
},
);
PasswordInput.displayName = 'Input';
export { Input, PasswordInput };