mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 11:41:44 +10:00
chore: fixed conflicts
Signed-off-by: Adithya Krishna <adi@documenso.com>
This commit is contained in:
@ -30,7 +30,7 @@ export const limitsHandler = async (
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
return res.status(500).json({
|
||||
error: ERROR_CODES.UNKNOWN,
|
||||
});
|
||||
}
|
||||
|
||||
@ -17,11 +17,11 @@
|
||||
"worker:test": "tsup worker/index.ts --format esm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/nodemailer-resend": "1.0.0",
|
||||
"@react-email/components": "^0.0.7",
|
||||
"@documenso/nodemailer-resend": "2.0.0",
|
||||
"@react-email/components": "^0.0.11",
|
||||
"nodemailer": "^6.9.3",
|
||||
"react-email": "^1.9.4",
|
||||
"resend": "^1.1.0"
|
||||
"react-email": "^1.9.5",
|
||||
"resend": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
import { Button, Section, Tailwind, Text } from '@react-email/components';
|
||||
|
||||
import * as config from '@documenso/tailwind-config';
|
||||
|
||||
import { TemplateDocumentImage } from './template-document-image';
|
||||
|
||||
export type TemplateConfirmationEmailProps = {
|
||||
confirmationLink: string;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
export const TemplateConfirmationEmail = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
|
||||
Welcome to Documenso!
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
Before you get started, please confirm your email address by clicking the button below:
|
||||
</Text>
|
||||
|
||||
<Section className="mb-6 mt-8 text-center">
|
||||
<Button
|
||||
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
|
||||
href={confirmationLink}
|
||||
>
|
||||
Confirm email
|
||||
</Button>
|
||||
<Text className="mt-8 text-center text-sm italic text-slate-400">
|
||||
You can also copy and paste this link into your browser: {confirmationLink} (link
|
||||
expires in 1 hour)
|
||||
</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
);
|
||||
};
|
||||
69
packages/email/templates/confirm-email.tsx
Normal file
69
packages/email/templates/confirm-email.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from '@react-email/components';
|
||||
|
||||
import config from '@documenso/tailwind-config';
|
||||
|
||||
import {
|
||||
TemplateConfirmationEmail,
|
||||
TemplateConfirmationEmailProps,
|
||||
} from '../template-components/template-confirmation-email';
|
||||
import { TemplateFooter } from '../template-components/template-footer';
|
||||
|
||||
export const ConfirmEmailTemplate = ({
|
||||
confirmationLink,
|
||||
assetBaseUrl,
|
||||
}: TemplateConfirmationEmailProps) => {
|
||||
const previewText = `Please confirm your email address`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: config.theme.extend.colors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img
|
||||
src={getAssetUrl('/static/logo.png')}
|
||||
alt="Documenso Logo"
|
||||
className="mb-4 h-6"
|
||||
/>
|
||||
|
||||
<TemplateConfirmationEmail
|
||||
confirmationLink={confirmationLink}
|
||||
assetBaseUrl={assetBaseUrl}
|
||||
/>
|
||||
</Section>
|
||||
</Container>
|
||||
<div className="mx-auto mt-12 max-w-xl" />
|
||||
|
||||
<Container className="mx-auto max-w-xl">
|
||||
<TemplateFooter isDocument={false} />
|
||||
</Container>
|
||||
</Section>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
@ -2,14 +2,13 @@ module.exports = {
|
||||
extends: [
|
||||
'next',
|
||||
'turbo',
|
||||
'prettier',
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:package-json/recommended',
|
||||
],
|
||||
|
||||
plugins: ['prettier', 'package-json'],
|
||||
plugins: ['prettier', 'package-json', 'unused-imports'],
|
||||
|
||||
env: {
|
||||
node: true,
|
||||
@ -30,12 +29,22 @@ module.exports = {
|
||||
},
|
||||
|
||||
rules: {
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-multi-spaces': [
|
||||
'error',
|
||||
{
|
||||
@ -67,5 +76,14 @@ module.exports = {
|
||||
// To handle this we want this rule to catch usages and highlight them as
|
||||
// warnings so we can write appropriate interfaces and guards later.
|
||||
'@typescript-eslint/consistent-type-assertions': ['warn', { assertionStyle: 'never' }],
|
||||
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'warn',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
fixStyle: 'separate-type-imports',
|
||||
disallowTypeAnnotations: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"eslint-plugin-package-json": "^0.1.4",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/lib/constants/crypto.ts
Normal file
1
packages/lib/constants/crypto.ts
Normal file
@ -0,0 +1 @@
|
||||
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||
2
packages/lib/constants/keyboard-shortcuts.ts
Normal file
2
packages/lib/constants/keyboard-shortcuts.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const SETTINGS_PAGE_SHORTCUT = 'N+S';
|
||||
export const DOCUMENTS_PAGE_SHORTCUT = 'N+D';
|
||||
@ -7,6 +7,8 @@ import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble';
|
||||
import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa';
|
||||
import { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||
import { ErrorCode } from './error-codes';
|
||||
|
||||
@ -22,13 +24,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
totpCode: {
|
||||
label: 'Two-factor Code',
|
||||
type: 'input',
|
||||
placeholder: 'Code from authenticator app',
|
||||
},
|
||||
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
|
||||
},
|
||||
authorize: async (credentials, _req) => {
|
||||
if (!credentials) {
|
||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||
}
|
||||
|
||||
const { email, password } = credentials;
|
||||
const { email, password, backupCode, totpCode } = credentials;
|
||||
|
||||
const user = await getUserByEmail({ email }).catch(() => {
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
@ -44,6 +52,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||
}
|
||||
|
||||
const is2faEnabled = isTwoFactorAuthenticationEnabled({ user });
|
||||
|
||||
if (is2faEnabled) {
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(
|
||||
totpCode
|
||||
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
|
||||
: ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(user.id),
|
||||
email: user.email,
|
||||
@ -88,11 +110,13 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
merged.id = retrieved.id;
|
||||
merged.name = retrieved.name;
|
||||
merged.email = retrieved.email;
|
||||
merged.emailVerified = retrieved.emailVerified;
|
||||
}
|
||||
|
||||
if (
|
||||
!merged.lastSignedIn ||
|
||||
DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now()
|
||||
merged.id &&
|
||||
(!merged.lastSignedIn ||
|
||||
DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now())
|
||||
) {
|
||||
merged.lastSignedIn = new Date().toISOString();
|
||||
|
||||
@ -111,6 +135,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
name: merged.name,
|
||||
email: merged.email,
|
||||
lastSignedIn: merged.lastSignedIn,
|
||||
emailVerified: merged.emailVerified,
|
||||
};
|
||||
},
|
||||
|
||||
@ -122,6 +147,8 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
id: Number(token.id),
|
||||
name: token.name,
|
||||
email: token.email,
|
||||
emailVerified:
|
||||
typeof token.emailVerified === 'string' ? new Date(token.emailVerified) : null,
|
||||
},
|
||||
} satisfies Session;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
35
packages/lib/next-auth/get-server-component-session.ts
Normal file
35
packages/lib/next-auth/get-server-component-session.ts
Normal file
@ -0,0 +1,35 @@
|
||||
'use server';
|
||||
|
||||
import { cache } from 'react';
|
||||
|
||||
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { NEXT_AUTH_OPTIONS } from './auth-options';
|
||||
|
||||
export const getServerComponentSession = cache(async () => {
|
||||
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
||||
|
||||
if (!session || !session.user?.email) {
|
||||
return { user: null, session: null };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
});
|
||||
|
||||
return { user, session };
|
||||
});
|
||||
|
||||
export const getRequiredServerComponentSession = cache(async () => {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
if (!user || !session) {
|
||||
throw new Error('No session found');
|
||||
}
|
||||
|
||||
return { user, session };
|
||||
});
|
||||
@ -1,4 +1,6 @@
|
||||
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
||||
'use server';
|
||||
|
||||
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { getServerSession as getNextAuthServerSession } from 'next-auth';
|
||||
|
||||
@ -26,29 +28,3 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
|
||||
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
export const getServerComponentSession = async () => {
|
||||
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);
|
||||
|
||||
if (!session || !session.user?.email) {
|
||||
return { user: null, session: null };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
email: session.user.email,
|
||||
},
|
||||
});
|
||||
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
export const getRequiredServerComponentSession = async () => {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
if (!user || !session) {
|
||||
throw new Error('No session found');
|
||||
}
|
||||
|
||||
return { user, session };
|
||||
};
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
"next-auth/"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -22,6 +24,8 @@
|
||||
"@documenso/prisma": "*",
|
||||
"@documenso/signing": "*",
|
||||
"@next-auth/prisma-adapter": "1.0.7",
|
||||
"@noble/ciphers": "0.4.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@scure/base": "^1.1.3",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
@ -31,6 +35,7 @@
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"oslo": "^0.17.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
|
||||
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { compare } from 'bcrypt';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { validateTwoFactorAuthentication } from './validate-2fa';
|
||||
|
||||
type DisableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const disableTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
user,
|
||||
password,
|
||||
}: DisableTwoFactorAuthenticationOptions) => {
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const isValid = await validateTwoFactorAuthentication({ backupCode, user });
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
|
||||
type EnableTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const enableTwoFactorAuthentication = async ({
|
||||
user,
|
||||
code,
|
||||
}: EnableTwoFactorAuthenticationOptions) => {
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
}
|
||||
|
||||
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
|
||||
|
||||
if (!isValidToken) {
|
||||
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const recoveryCodes = getBackupCodes({ user: updatedUser });
|
||||
|
||||
return { recoveryCodes };
|
||||
};
|
||||
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
interface GetBackupCodesOptions {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const ZBackupCodeSchema = z.array(z.string());
|
||||
|
||||
export const getBackupCodes = ({ user }: GetBackupCodesOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new Error('User has not enabled 2FA');
|
||||
}
|
||||
|
||||
if (!user.twoFactorBackupCodes) {
|
||||
throw new Error('User has no backup codes');
|
||||
}
|
||||
|
||||
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString(
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const data = JSON.parse(secret);
|
||||
|
||||
const result = ZBackupCodeSchema.safeParse(data);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
|
||||
type IsTwoFactorAuthenticationEnabledOptions = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const isTwoFactorAuthenticationEnabled = ({
|
||||
user,
|
||||
}: IsTwoFactorAuthenticationEnabledOptions) => {
|
||||
return (
|
||||
user.twoFactorEnabled &&
|
||||
user.identityProvider === 'DOCUMENSO' &&
|
||||
typeof DOCUMENSO_ENCRYPTION_KEY === 'string'
|
||||
);
|
||||
};
|
||||
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { compare } from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricEncrypt } from '../../universal/crypto';
|
||||
|
||||
type SetupTwoFactorAuthenticationOptions = {
|
||||
user: User;
|
||||
password: string;
|
||||
};
|
||||
|
||||
const ISSUER = 'Documenso';
|
||||
|
||||
export const setupTwoFactorAuthentication = async ({
|
||||
user,
|
||||
password,
|
||||
}: SetupTwoFactorAuthenticationOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
if (user.identityProvider !== 'DOCUMENSO') {
|
||||
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
|
||||
}
|
||||
|
||||
const isCorrectPassword = await compare(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
throw new Error(ErrorCode.INCORRECT_PASSWORD);
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(10);
|
||||
|
||||
const backupCodes = new Array(10)
|
||||
.fill(null)
|
||||
.map(() => crypto.randomBytes(5).toString('hex'))
|
||||
.map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase());
|
||||
|
||||
const accountName = user.email;
|
||||
const uri = createTOTPKeyURI(ISSUER, accountName, secret);
|
||||
const encodedSecret = base32.encode(secret);
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
twoFactorEnabled: false,
|
||||
twoFactorBackupCodes: symmetricEncrypt({
|
||||
data: JSON.stringify(backupCodes),
|
||||
key: key,
|
||||
}),
|
||||
twoFactorSecret: symmetricEncrypt({
|
||||
data: encodedSecret,
|
||||
key: key,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
secret: encodedSecret,
|
||||
uri,
|
||||
};
|
||||
};
|
||||
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { ErrorCode } from '../../next-auth/error-codes';
|
||||
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
|
||||
import { verifyBackupCode } from './verify-backup-code';
|
||||
|
||||
type ValidateTwoFactorAuthenticationOptions = {
|
||||
totpCode?: string;
|
||||
backupCode?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const validateTwoFactorAuthentication = async ({
|
||||
backupCode,
|
||||
totpCode,
|
||||
user,
|
||||
}: ValidateTwoFactorAuthenticationOptions) => {
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED);
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET);
|
||||
}
|
||||
|
||||
if (totpCode) {
|
||||
return await verifyTwoFactorAuthenticationToken({ user, totpCode });
|
||||
}
|
||||
|
||||
if (backupCode) {
|
||||
return await verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
|
||||
throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
|
||||
};
|
||||
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { base32 } from '@scure/base';
|
||||
import { TOTPController } from 'oslo/otp';
|
||||
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
|
||||
import { symmetricDecrypt } from '../../universal/crypto';
|
||||
|
||||
const totp = new TOTPController();
|
||||
|
||||
type VerifyTwoFactorAuthenticationTokenOptions = {
|
||||
user: User;
|
||||
totpCode: string;
|
||||
};
|
||||
|
||||
export const verifyTwoFactorAuthenticationToken = async ({
|
||||
user,
|
||||
totpCode,
|
||||
}: VerifyTwoFactorAuthenticationTokenOptions) => {
|
||||
const key = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error('user missing 2fa secret');
|
||||
}
|
||||
|
||||
const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString(
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const isValidToken = await totp.verify(totpCode, base32.decode(secret));
|
||||
|
||||
return isValidToken;
|
||||
};
|
||||
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
import { getBackupCodes } from './get-backup-code';
|
||||
|
||||
type VerifyBackupCodeParams = {
|
||||
user: User;
|
||||
backupCode: string;
|
||||
};
|
||||
|
||||
export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => {
|
||||
const userBackupCodes = await getBackupCodes({ user });
|
||||
|
||||
if (!userBackupCodes) {
|
||||
throw new Error('User has no backup codes');
|
||||
}
|
||||
|
||||
return userBackupCodes.includes(backupCode);
|
||||
};
|
||||
@ -1,4 +1,4 @@
|
||||
import { hashSync as bcryptHashSync } from 'bcrypt';
|
||||
import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
56
packages/lib/server-only/auth/send-confirmation-email.ts
Normal file
56
packages/lib/server-only/auth/send-confirmation-email.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { ConfirmEmailTemplate } from '@documenso/email/templates/confirm-email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface SendConfirmationEmailProps {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendConfirmationEmail = async ({ userId }: SendConfirmationEmailProps) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
VerificationToken: {
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [verificationToken] = user.VerificationToken;
|
||||
|
||||
if (!verificationToken?.token) {
|
||||
throw new Error('Verification token not found for the user');
|
||||
}
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const confirmationLink = `${assetBaseUrl}/verify-email/${verificationToken.token}`;
|
||||
const senderName = process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso';
|
||||
const senderAdress = process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com';
|
||||
|
||||
const confirmationTemplate = createElement(ConfirmEmailTemplate, {
|
||||
assetBaseUrl,
|
||||
confirmationLink,
|
||||
});
|
||||
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
name: user.name || '',
|
||||
},
|
||||
from: {
|
||||
name: senderName,
|
||||
address: senderAdress,
|
||||
},
|
||||
subject: 'Please confirm your email',
|
||||
html: render(confirmationTemplate),
|
||||
text: render(confirmationTemplate, { plainText: true }),
|
||||
});
|
||||
};
|
||||
@ -94,6 +94,7 @@ export const completeDocumentWithToken = async ({
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export interface DuplicateDocumentByIdOptions {
|
||||
id: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByIdOptions) => {
|
||||
const document = await prisma.document.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
userId: userId,
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
userId: true,
|
||||
documentData: {
|
||||
select: {
|
||||
data: true,
|
||||
initialData: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
documentMeta: {
|
||||
select: {
|
||||
message: true,
|
||||
subject: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createdDocument = await prisma.document.create({
|
||||
data: {
|
||||
title: document.title,
|
||||
User: {
|
||||
connect: {
|
||||
id: document.userId,
|
||||
},
|
||||
},
|
||||
documentData: {
|
||||
create: {
|
||||
...document.documentData,
|
||||
data: document.documentData.initialData,
|
||||
},
|
||||
},
|
||||
documentMeta: {
|
||||
create: {
|
||||
...document.documentMeta,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return createdDocument.id;
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
import { match } from 'ts-pattern';
|
||||
import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Document, Prisma, SigningStatus } from '@documenso/prisma/client';
|
||||
@ -16,6 +17,7 @@ export interface FindDocumentsOptions {
|
||||
column: keyof Omit<Document, 'document'>;
|
||||
direction: 'asc' | 'desc';
|
||||
};
|
||||
period?: '' | '7d' | '14d' | '30d';
|
||||
}
|
||||
|
||||
export const findDocuments = async ({
|
||||
@ -25,6 +27,7 @@ export const findDocuments = async ({
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
orderBy,
|
||||
period,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@ -35,14 +38,16 @@ export const findDocuments = async ({
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
const termFilters = !term
|
||||
? undefined
|
||||
: ({
|
||||
const termFilters = match(term)
|
||||
.with(P.string.minLength(1), () => {
|
||||
return {
|
||||
title: {
|
||||
contains: term,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
} as const);
|
||||
} as const;
|
||||
})
|
||||
.otherwise(() => undefined);
|
||||
|
||||
const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
|
||||
.with(ExtendedDocumentStatus.ALL, () => ({
|
||||
@ -113,12 +118,24 @@ export const findDocuments = async ({
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
const whereClause = {
|
||||
...termFilters,
|
||||
...filters,
|
||||
};
|
||||
|
||||
if (period) {
|
||||
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
|
||||
|
||||
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
|
||||
|
||||
whereClause.createdAt = {
|
||||
gte: startOfPeriod.toJSDate(),
|
||||
};
|
||||
}
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
where: {
|
||||
...termFilters,
|
||||
...filters,
|
||||
},
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
|
||||
99
packages/lib/server-only/document/resend-document.tsx
Normal file
99
packages/lib/server-only/document/resend-document.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type ResendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
recipients: number[];
|
||||
};
|
||||
|
||||
export const resendDocument = async ({ documentId, userId, recipients }: ResendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
Recipient: {
|
||||
where: {
|
||||
id: {
|
||||
in: recipients,
|
||||
},
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
},
|
||||
},
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
const customEmail = document?.documentMeta;
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.DRAFT) {
|
||||
throw new Error('Can not send draft document');
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name } = recipient;
|
||||
|
||||
const customEmailTemplate = {
|
||||
'signer.name': name,
|
||||
'signer.email': email,
|
||||
'document.name': document.title,
|
||||
};
|
||||
|
||||
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
|
||||
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
|
||||
});
|
||||
|
||||
await mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: FROM_NAME,
|
||||
address: FROM_ADDRESS,
|
||||
},
|
||||
subject: customEmail?.subject
|
||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||
: 'Please sign this document',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
};
|
||||
@ -105,7 +105,7 @@ export const extractDistinctUserId = (jwt: JWT | null, request: NextRequest): st
|
||||
const config = extractPostHogConfig();
|
||||
|
||||
const email = jwt?.email;
|
||||
const userId = jwt?.id.toString();
|
||||
const userId = jwt?.id?.toString();
|
||||
|
||||
let fallbackDistinctId = nanoid();
|
||||
|
||||
|
||||
41
packages/lib/server-only/user/generate-confirmation-token.ts
Normal file
41
packages/lib/server-only/user/generate-confirmation-token.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ONE_HOUR } from '../../constants/time';
|
||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||
|
||||
const IDENTIFIER = 'confirmation-email';
|
||||
|
||||
export const generateConfirmationToken = async ({ email }: { email: string }) => {
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: IDENTIFIER,
|
||||
token: token,
|
||||
expires: new Date(Date.now() + ONE_HOUR),
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdToken) {
|
||||
throw new Error(`Failed to create the verification token`);
|
||||
}
|
||||
|
||||
return sendConfirmationEmail({ userId: user.id });
|
||||
};
|
||||
34
packages/lib/server-only/user/get-user-monthly-growth.ts
Normal file
34
packages/lib/server-only/user/get-user-monthly-growth.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetUserMonthlyGrowthResult = Array<{
|
||||
month: string;
|
||||
count: number;
|
||||
cume_count: number;
|
||||
}>;
|
||||
|
||||
type GetUserMonthlyGrowthQueryResult = Array<{
|
||||
month: Date;
|
||||
count: bigint;
|
||||
cume_count: bigint;
|
||||
}>;
|
||||
|
||||
export const getUserMonthlyGrowth = async () => {
|
||||
const result = await prisma.$queryRaw<GetUserMonthlyGrowthQueryResult>`
|
||||
SELECT
|
||||
DATE_TRUNC('month', "createdAt") AS "month",
|
||||
COUNT("id") as "count",
|
||||
SUM(COUNT("id")) OVER (ORDER BY DATE_TRUNC('month', "createdAt")) as "cume_count"
|
||||
FROM "User"
|
||||
GROUP BY "month"
|
||||
ORDER BY "month" DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return result.map((row) => ({
|
||||
month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'),
|
||||
count: Number(row.count),
|
||||
cume_count: Number(row.cume_count),
|
||||
}));
|
||||
};
|
||||
41
packages/lib/server-only/user/send-confirmation-token.ts
Normal file
41
packages/lib/server-only/user/send-confirmation-token.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ONE_HOUR } from '../../constants/time';
|
||||
import { sendConfirmationEmail } from '../auth/send-confirmation-email';
|
||||
|
||||
const IDENTIFIER = 'confirmation-email';
|
||||
|
||||
export const sendConfirmationToken = async ({ email }: { email: string }) => {
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const createdToken = await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: IDENTIFIER,
|
||||
token: token,
|
||||
expires: new Date(Date.now() + ONE_HOUR),
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdToken) {
|
||||
throw new Error(`Failed to create the verification token`);
|
||||
}
|
||||
|
||||
return sendConfirmationEmail({ userId: user.id });
|
||||
};
|
||||
70
packages/lib/server-only/user/verify-email.ts
Normal file
70
packages/lib/server-only/user/verify-email.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { sendConfirmationToken } from './send-confirmation-token';
|
||||
|
||||
export type VerifyEmailProps = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const verifyEmail = async ({ token }: VerifyEmailProps) => {
|
||||
const verificationToken = await prisma.verificationToken.findFirst({
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verificationToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// check if the token is valid or expired
|
||||
const valid = verificationToken.expires > new Date();
|
||||
|
||||
if (!valid) {
|
||||
const mostRecentToken = await prisma.verificationToken.findFirst({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// If there isn't a recent token or it's older than 1 hour, send a new token
|
||||
if (
|
||||
!mostRecentToken ||
|
||||
DateTime.now().minus({ hours: 1 }).toJSDate() > mostRecentToken.createdAt
|
||||
) {
|
||||
await sendConfirmationToken({ email: verificationToken.user.email });
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
const [updatedUser, deletedToken] = await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: {
|
||||
id: verificationToken.userId,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
},
|
||||
}),
|
||||
prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
userId: verificationToken.userId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!updatedUser || !deletedToken) {
|
||||
throw new Error('Something went wrong while verifying your email. Please try again.');
|
||||
}
|
||||
|
||||
return !!updatedUser && !!deletedToken;
|
||||
};
|
||||
32
packages/lib/universal/crypto.ts
Normal file
32
packages/lib/universal/crypto.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
|
||||
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils';
|
||||
import { managedNonce } from '@noble/ciphers/webcrypto/utils';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
|
||||
export type SymmetricEncryptOptions = {
|
||||
key: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => {
|
||||
const keyAsBytes = sha256(key);
|
||||
const dataAsBytes = utf8ToBytes(data);
|
||||
|
||||
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
|
||||
|
||||
return bytesToHex(chacha.encrypt(dataAsBytes));
|
||||
};
|
||||
|
||||
export type SymmetricDecryptOptions = {
|
||||
key: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => {
|
||||
const keyAsBytes = sha256(key);
|
||||
const dataAsBytes = hexToBytes(data);
|
||||
|
||||
const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you
|
||||
|
||||
return chacha.decrypt(dataAsBytes);
|
||||
};
|
||||
3
packages/lib/universal/unit-convertions.ts
Normal file
3
packages/lib/universal/unit-convertions.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function megabytesToBytes(megabytes: number) {
|
||||
return megabytes * 1000000;
|
||||
}
|
||||
@ -10,7 +10,7 @@ import slugify from '@sindresorhus/slugify';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ONE_HOUR, ONE_SECOND } from '../../constants/time';
|
||||
import { getServerComponentSession } from '../../next-auth/get-server-session';
|
||||
import { getServerComponentSession } from '../../next-auth/get-server-component-session';
|
||||
import { alphaid } from '../id';
|
||||
|
||||
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
||||
|
||||
@ -23,6 +23,11 @@ export const getDatabaseUrl = () => {
|
||||
process.env.NEXT_PRIVATE_DIRECT_DATABASE_URL = process.env.POSTGRES_URL_NON_POOLING;
|
||||
}
|
||||
|
||||
// If we don't have a database URL, we can't normalize it.
|
||||
if (!process.env.NEXT_PRIVATE_DATABASE_URL) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// We change the protocol from `postgres:` to `https:` so we can construct a easily
|
||||
// mofifiable URL.
|
||||
const url = new URL(process.env.NEXT_PRIVATE_DATABASE_URL.replace('postgres://', 'https://'));
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationToken" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VerificationToken" ADD CONSTRAINT "VerificationToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@ -0,0 +1,3 @@
|
||||
UPDATE "User"
|
||||
SET "emailVerified" = CURRENT_TIMESTAMP
|
||||
WHERE "emailVerified" IS NULL;
|
||||
@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Document" ADD COLUMN "completedAt" TIMESTAMP(3);
|
||||
|
||||
UPDATE "Document" SET "completedAt" = "updatedAt" WHERE "status" = 'COMPLETED';
|
||||
@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "twoFactorBackupCodes" TEXT,
|
||||
ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "twoFactorSecret" TEXT;
|
||||
@ -8,6 +8,7 @@
|
||||
"build": "prisma generate",
|
||||
"format": "prisma format",
|
||||
"clean": "rimraf node_modules",
|
||||
"post-install": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate-dev": "prisma migrate dev",
|
||||
"prisma:migrate-deploy": "prisma migrate deploy",
|
||||
|
||||
@ -19,23 +19,27 @@ enum Role {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
Subscription Subscription?
|
||||
PasswordResetToken PasswordResetToken[]
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
Subscription Subscription?
|
||||
PasswordResetToken PasswordResetToken[]
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
twoFactorBackupCodes String?
|
||||
VerificationToken VerificationToken[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@ -49,6 +53,16 @@ model PasswordResetToken {
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
id Int @id @default(autoincrement())
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
ACTIVE
|
||||
PAST_DUE
|
||||
@ -120,6 +134,7 @@ model Document {
|
||||
documentMeta DocumentMeta?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
completedAt DateTime?
|
||||
|
||||
@@unique([documentDataId])
|
||||
@@index([userId])
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -17,5 +19,6 @@
|
||||
"@trpc/server": "^10.36.0",
|
||||
"superjson": "^1.13.1",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||
import { compareSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||
|
||||
import { procedure, router } from '../trpc';
|
||||
import { ZSignUpMutationSchema } from './schema';
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
|
||||
|
||||
export const authRouter = router({
|
||||
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
|
||||
try {
|
||||
const { name, email, password, signature } = input;
|
||||
|
||||
return await createUser({ name, email, password, signature });
|
||||
const user = await createUser({ name, email, password, signature });
|
||||
|
||||
await sendConfirmationToken({ email: user.email });
|
||||
|
||||
return user;
|
||||
} catch (err) {
|
||||
let message =
|
||||
'We were unable to create your account. Please review the information you provided and try again.';
|
||||
@ -25,4 +32,23 @@ export const authRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
verifyPassword: authenticatedProcedure
|
||||
.input(ZVerifyPasswordMutationSchema)
|
||||
.mutation(({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
const { password } = input;
|
||||
|
||||
if (!user.password) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: ErrorCode.INCORRECT_PASSWORD,
|
||||
});
|
||||
}
|
||||
|
||||
const valid = compareSync(password, user.password);
|
||||
|
||||
return valid;
|
||||
}),
|
||||
});
|
||||
|
||||
@ -8,3 +8,5 @@ export const ZSignUpMutationSchema = z.object({
|
||||
});
|
||||
|
||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||
|
||||
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });
|
||||
|
||||
@ -3,8 +3,10 @@ import { TRPCError } from '@trpc/server';
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
|
||||
import { duplicateDocumentById } from '@documenso/lib/server-only/document/duplicate-document-by-id';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { resendDocument } from '@documenso/lib/server-only/document/resend-document';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
@ -15,6 +17,7 @@ import {
|
||||
ZDeleteDraftDocumentMutationSchema,
|
||||
ZGetDocumentByIdQuerySchema,
|
||||
ZGetDocumentByTokenQuerySchema,
|
||||
ZResendDocumentMutationSchema,
|
||||
ZSendDocumentMutationSchema,
|
||||
ZSetFieldsForDocumentMutationSchema,
|
||||
ZSetRecipientsForDocumentMutationSchema,
|
||||
@ -172,4 +175,44 @@ export const documentRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
resendDocument: authenticatedProcedure
|
||||
.input(ZResendDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId, recipients } = input;
|
||||
|
||||
return await resendDocument({
|
||||
userId: ctx.user.id,
|
||||
documentId,
|
||||
recipients,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to resend this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
duplicateDocument: authenticatedProcedure
|
||||
.input(ZGetDocumentByIdQuerySchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { id } = input;
|
||||
|
||||
return await duplicateDocumentById({
|
||||
id,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We are unable to duplicate this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -60,6 +60,11 @@ export const ZSendDocumentMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export const ZResendDocumentMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
recipients: z.array(z.number()).min(1),
|
||||
});
|
||||
|
||||
export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSchema>;
|
||||
|
||||
export const ZDeleteDraftDocumentMutationSchema = z.object({
|
||||
|
||||
@ -3,11 +3,13 @@ import { TRPCError } from '@trpc/server';
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
||||
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
||||
|
||||
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZConfirmEmailMutationSchema,
|
||||
ZForgotPasswordFormSchema,
|
||||
ZResetPasswordFormSchema,
|
||||
ZRetrieveUserByIdQuerySchema,
|
||||
@ -110,4 +112,25 @@ export const profileRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
sendConfirmationEmail: procedure
|
||||
.input(ZConfirmEmailMutationSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const { email } = input;
|
||||
|
||||
return sendConfirmationToken({ email });
|
||||
} catch (err) {
|
||||
let message = 'We were unable to send a confirmation email. Please try again.';
|
||||
|
||||
if (err instanceof Error) {
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -23,8 +23,13 @@ export const ZResetPasswordFormSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ZConfirmEmailMutationSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
});
|
||||
|
||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
export type TResetPasswordFormSchema = z.infer<typeof ZResetPasswordFormSchema>;
|
||||
export type TConfirmEmailMutationSchema = z.infer<typeof ZConfirmEmailMutationSchema>;
|
||||
|
||||
@ -5,6 +5,7 @@ import { fieldRouter } from './field-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { shareLinkRouter } from './share-link-router/router';
|
||||
import { procedure, router } from './trpc';
|
||||
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
|
||||
|
||||
export const appRouter = router({
|
||||
health: procedure.query(() => {
|
||||
@ -16,6 +17,7 @@ export const appRouter = router({
|
||||
field: fieldRouter,
|
||||
admin: adminRouter,
|
||||
shareLink: shareLinkRouter,
|
||||
twoFactorAuthentication: twoFactorAuthenticationRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
105
packages/trpc/server/two-factor-authentication-router/router.ts
Normal file
105
packages/trpc/server/two-factor-authentication-router/router.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@ -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>;
|
||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -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;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
import React, { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import { Copy, Share } from 'lucide-react';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
@ -25,11 +25,17 @@ import {
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
token: string;
|
||||
token?: string;
|
||||
documentId: number;
|
||||
trigger?: (_props: { loading: boolean; disabled: boolean }) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocumentShareButton = ({ token, documentId, className }: DocumentShareButtonProps) => {
|
||||
export const DocumentShareButton = ({
|
||||
token,
|
||||
documentId,
|
||||
className,
|
||||
trigger,
|
||||
}: DocumentShareButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { copyShareLink, createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
|
||||
@ -81,6 +87,12 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
|
||||
slug = result.slug;
|
||||
}
|
||||
|
||||
// Ensuring we've prewarmed the opengraph image for the Twitter
|
||||
await fetch(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}/opengraph`, {
|
||||
// We don't care about the response, so we can use no-cors
|
||||
mode: 'no-cors',
|
||||
});
|
||||
|
||||
window.open(
|
||||
generateTwitterIntent(
|
||||
`I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`,
|
||||
@ -94,16 +106,21 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!token || !documentId}
|
||||
className={cn('flex-1', className)}
|
||||
loading={isLoading || isCopyingShareLink}
|
||||
>
|
||||
{!isLoading && !isCopyingShareLink && <Share className="mr-2 h-5 w-5" />}
|
||||
Share
|
||||
</Button>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger?.({
|
||||
disabled: !token || !documentId,
|
||||
loading: isLoading || isCopyingShareLink,
|
||||
}) || (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!token || !documentId}
|
||||
className={cn('flex-1', className)}
|
||||
loading={isLoading || isCopyingShareLink}
|
||||
>
|
||||
{!isLoading && !isCopyingShareLink && <Share className="mr-2 h-5 w-5" />}
|
||||
Share
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="end">
|
||||
@ -126,6 +143,19 @@ export const DocumentShareButton = ({ token, documentId, className }: DocumentSh
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'}
|
||||
</span>
|
||||
<div
|
||||
className={cn('bg-muted/40 mt-4 aspect-video overflow-hidden rounded-lg border', {
|
||||
'animate-pulse': !shareLink?.slug,
|
||||
})}
|
||||
>
|
||||
{shareLink?.slug && (
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${shareLink.slug}/opengraph`}
|
||||
alt="sharing link"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="mt-4" onClick={onTweetClick}>
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
"index.tsx"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint \"**/*.ts*\"",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -52,6 +53,7 @@
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
"@radix-ui/react-toast": "^1.1.3",
|
||||
"@radix-ui/react-toggle": "^1.0.2",
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@tanstack/react-table": "^8.9.1",
|
||||
"class-variance-authority": "^0.6.0",
|
||||
|
||||
@ -9,8 +9,10 @@ import { cn } from '../lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
|
||||
checkClassName?: string;
|
||||
}
|
||||
>(({ className, checkClassName, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@ -19,8 +21,10 @@ const Checkbox = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('text-primary flex items-center justify-center')}>
|
||||
<Check className="h-4 w-4" />
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('text-primary flex items-center justify-center', checkClassName)}
|
||||
>
|
||||
<Check className="h-3 w-3 stroke-[3px]" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
|
||||
@ -25,13 +25,18 @@ const Command = React.forwardRef<
|
||||
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
type CommandDialogProps = DialogProps;
|
||||
type CommandDialogProps = DialogProps & {
|
||||
commandProps?: React.ComponentPropsWithoutRef<typeof CommandPrimitive>;
|
||||
};
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
const CommandDialog = ({ children, commandProps, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-2xl">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<Command
|
||||
{...commandProps}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
|
||||
@ -11,6 +11,8 @@ const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogPortal = ({
|
||||
children,
|
||||
position = 'start',
|
||||
@ -51,8 +53,9 @@ const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
position?: 'start' | 'end' | 'center';
|
||||
hideClose?: boolean;
|
||||
}
|
||||
>(({ className, children, position = 'start', ...props }, ref) => (
|
||||
>(({ className, children, position = 'start', hideClose = false, ...props }, ref) => (
|
||||
<DialogPortal position={position}>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@ -64,10 +67,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
@ -125,4 +130,5 @@ export {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogPortal,
|
||||
DialogClose,
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ import { Variants, motion } from 'framer-motion';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
@ -96,6 +97,7 @@ export const DocumentDropzone = ({
|
||||
void onDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
maxSize: megabytesToBytes(50),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@ -176,7 +176,7 @@ export const AddSignersFormPartial = ({
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-10 items-center justify-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="justify-left inline-flex h-10 w-10 items-center text-slate-500 hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
|
||||
@ -61,8 +61,14 @@ export const DocumentFlowFormContainerContent = ({
|
||||
...props
|
||||
}: DocumentFlowFormContainerContentProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-1 flex-col', className)} {...props}>
|
||||
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2">{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -90,7 +96,6 @@ export type DocumentFlowFormContainerStepProps = {
|
||||
};
|
||||
|
||||
export const DocumentFlowFormContainerStep = ({
|
||||
title,
|
||||
step,
|
||||
maxStep,
|
||||
}: DocumentFlowFormContainerStepProps) => {
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -207,7 +207,7 @@ export const PDFViewer = ({
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border my-8 overflow-hidden rounded border first:mt-0 last:mb-0"
|
||||
className="border-border my-8 overflow-hidden rounded border will-change-transform first:mt-0 last:mb-0"
|
||||
>
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
|
||||
54
packages/ui/primitives/theme-switcher.tsx
Normal file
54
packages/ui/primitives/theme-switcher.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Monitor, MoonStar, Sun } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
|
||||
export const ThemeSwitcher = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
return (
|
||||
<div className="bg-muted flex items-center gap-x-1 rounded-full p-1">
|
||||
<button
|
||||
className="text-muted-foreground relative z-10 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
onClick={() => setTheme('light')}
|
||||
>
|
||||
{isMounted && theme === 'light' && (
|
||||
<motion.div
|
||||
className="bg-background absolute inset-0 rounded-full mix-blend-exclusion"
|
||||
layoutId="selected-theme"
|
||||
/>
|
||||
)}
|
||||
<Sun className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="text-muted-foreground relative z-10 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
onClick={() => setTheme('dark')}
|
||||
>
|
||||
{isMounted && theme === 'dark' && (
|
||||
<motion.div
|
||||
className="bg-background absolute inset-0 rounded-full mix-blend-exclusion"
|
||||
layoutId="selected-theme"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MoonStar className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="text-muted-foreground relative z-10 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
onClick={() => setTheme('system')}
|
||||
>
|
||||
{isMounted && theme === 'system' && (
|
||||
<motion.div
|
||||
className="bg-background absolute inset-0 rounded-full mix-blend-exclusion"
|
||||
layoutId="selected-theme"
|
||||
/>
|
||||
)}
|
||||
<Monitor className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -93,3 +93,24 @@
|
||||
mask-composite: exclude;
|
||||
-webkit-mask-composite: xor;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgb(100 116 139 / 1);
|
||||
border-radius: 10px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(100 116 139 / 0.5);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user