-
-
Authenticator app
-
-
- Create one-time passwords that serve as a secondary authentication method for confirming
- your identity when requested during the sign-in process.
-
-
-
-
- {isTwoFactorEnabled ? (
- setModalState('disable')} size="sm">
- Disable 2FA
-
- ) : (
- setModalState('enable')} size="sm">
- Enable 2FA
-
- )}
-
+
+ {isTwoFactorEnabled ? (
+ setModalState('disable')}>
+ Disable 2FA
+
+ ) : (
+ setModalState('enable')}>Enable 2FA
+ )}
-
- onOpenChange(false)}>
+
+ onOpenChange(false)}>
Cancel
@@ -157,7 +158,7 @@ export const DisableAuthenticatorAppDialog = ({
>
Disable 2FA
-
+
diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
index 0db1c8b50..7a181c4cc 100644
--- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
+++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx
@@ -15,6 +15,7 @@ import {
Dialog,
DialogContent,
DialogDescription,
+ DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
@@ -190,15 +191,15 @@ export const EnableAuthenticatorAppDialog = ({
)}
/>
-
- onOpenChange(false)}>
+
+ onOpenChange(false)}>
Cancel
Continue
-
+
);
@@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({
)}
/>
-
- onOpenChange(false)}>
+
+ onOpenChange(false)}>
Cancel
Enable 2FA
-
+
))
diff --git a/apps/web/src/components/forms/2fa/recovery-codes.tsx b/apps/web/src/components/forms/2fa/recovery-codes.tsx
index 7e8950227..29834c74a 100644
--- a/apps/web/src/components/forms/2fa/recovery-codes.tsx
+++ b/apps/web/src/components/forms/2fa/recovery-codes.tsx
@@ -7,7 +7,6 @@ import { Button } from '@documenso/ui/primitives/button';
import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog';
type RecoveryCodesProps = {
- // backupCodes: string[] | null;
isTwoFactorEnabled: boolean;
};
@@ -16,22 +15,13 @@ export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => {
return (
<>
-
-
-
Recovery Codes
-
-
- Recovery codes are used to access your account in the event that you lose access to your
- authenticator app.
-
-
-
-
- setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
- View Codes
-
-
-
+
setIsOpen(true)}
+ disabled={!isTwoFactorEnabled}
+ >
+ View Codes
+
-
- onOpenChange(false)}>
+
+ onOpenChange(false)}>
Cancel
Continue
-
+
);
diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx
index 0fa5ad462..03f95ff7f 100644
--- a/apps/web/src/components/forms/password.tsx
+++ b/apps/web/src/components/forms/password.tsx
@@ -137,7 +137,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
/>
-
+
{isSubmitting ? 'Updating password...' : 'Update password'}
diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx
index 7036f4e43..2c278292f 100644
--- a/apps/web/src/components/forms/profile.tsx
+++ b/apps/web/src/components/forms/profile.tsx
@@ -121,10 +121,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
onChange(v ?? '')}
/>
diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx
index 17bb2c57c..b3e4ea019 100644
--- a/apps/web/src/components/forms/signin.tsx
+++ b/apps/web/src/components/forms/signin.tsx
@@ -12,7 +12,13 @@ import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
@@ -111,7 +117,6 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
const result = await signIn('credentials', {
...credentials,
-
callbackUrl: LOGIN_REDIRECT_PATH,
redirect: false,
});
@@ -270,21 +275,23 @@ export const SignInForm = ({ className, isGoogleSSOEnabled }: SignInFormProps) =
)}
/>
)}
+
+
+
+ {twoFactorAuthenticationMethod === 'totp'
+ ? 'Use Backup Code'
+ : 'Use Authenticator'}
+
+
+
+ {isSubmitting ? 'Signing in...' : 'Sign In'}
+
+
-
-
-
- {twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
-
-
-
- {isSubmitting ? 'Signing in...' : 'Sign In'}
-
-
diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx
index ebfbf72c9..f38ab15d1 100644
--- a/apps/web/src/components/forms/signup.tsx
+++ b/apps/web/src/components/forms/signup.tsx
@@ -172,6 +172,7 @@ export const SignUpForm = ({ className, isGoogleSSOEnabled }: SignUpFormProps) =
onChange(v ?? '')}
/>
diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts
index 4039703b8..365b6ec40 100644
--- a/apps/web/src/pages/api/auth/[...nextauth].ts
+++ b/apps/web/src/pages/api/auth/[...nextauth].ts
@@ -1,17 +1,65 @@
-// import { NextApiRequest, NextApiResponse } from 'next';
+import type { NextApiRequest, NextApiResponse } from 'next';
+
import NextAuth from 'next-auth';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
+import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
+import { prisma } from '@documenso/prisma';
+import { UserSecurityAuditLogType } from '@documenso/prisma/client';
-export default NextAuth({
- ...NEXT_AUTH_OPTIONS,
- pages: {
- signIn: '/signin',
- signOut: '/signout',
- error: '/signin',
- },
-});
+export default async function auth(req: NextApiRequest, res: NextApiResponse) {
+ const { ipAddress, userAgent } = extractNextApiRequestMetadata(req);
-// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
-// res.json({ hello: 'world' });
-// }
+ return await NextAuth(req, res, {
+ ...NEXT_AUTH_OPTIONS,
+ pages: {
+ signIn: '/signin',
+ signOut: '/signout',
+ error: '/signin',
+ },
+ events: {
+ signIn: async ({ user }) => {
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId: user.id,
+ ipAddress,
+ userAgent,
+ type: UserSecurityAuditLogType.SIGN_IN,
+ },
+ });
+ },
+ signOut: async ({ token }) => {
+ const userId = typeof token.id === 'string' ? parseInt(token.id) : token.id;
+
+ if (isNaN(userId)) {
+ return;
+ }
+
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId,
+ ipAddress,
+ userAgent,
+ type: UserSecurityAuditLogType.SIGN_OUT,
+ },
+ });
+ },
+ linkAccount: async ({ user }) => {
+ const userId = typeof user.id === 'string' ? parseInt(user.id) : user.id;
+
+ if (isNaN(userId)) {
+ return;
+ }
+
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId,
+ ipAddress,
+ userAgent,
+ type: UserSecurityAuditLogType.ACCOUNT_SSO_LINK,
+ },
+ });
+ },
+ },
+ });
+}
diff --git a/docker/Dockerfile b/docker/Dockerfile
index ecdd3b91b..4952b0bbd 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -39,6 +39,14 @@ ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
+# Encryption keys
+ARG NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE"
+ENV NEXT_PRIVATE_ENCRYPTION_KEY="$NEXT_PRIVATE_ENCRYPTION_KEY"
+
+ARG NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
+ENV NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="$NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY"
+
+
# Uncomment and use build args to enable remote caching
# ARG TURBO_TEAM
# ENV TURBO_TEAM=$TURBO_TEAM
diff --git a/package-lock.json b/package-lock.json
index 69825e8d8..9012d3f29 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -158,6 +158,7 @@
"sharp": "0.33.1",
"ts-pattern": "^5.0.5",
"typescript": "5.2.2",
+ "ua-parser-js": "^1.0.37",
"uqr": "^0.1.2",
"zod": "^3.22.4"
},
@@ -166,7 +167,8 @@
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0",
"@types/react": "18.2.18",
- "@types/react-dom": "18.2.7"
+ "@types/react-dom": "18.2.7",
+ "@types/ua-parser-js": "^0.7.39"
}
},
"apps/web/node_modules/@types/node": {
@@ -6756,6 +6758,12 @@
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A=="
},
+ "node_modules/@types/ua-parser-js": {
+ "version": "0.7.39",
+ "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz",
+ "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==",
+ "dev": true
+ },
"node_modules/@types/unist": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
@@ -18643,6 +18651,28 @@
"node": ">=14.17"
}
},
+ "node_modules/ua-parser-js": {
+ "version": "1.0.37",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
+ "integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ua-parser-js"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/faisalman"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/faisalman"
+ }
+ ],
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts
index 9a07ec3c7..45b6dea03 100644
--- a/packages/app-tests/e2e/test-auth-flow.spec.ts
+++ b/packages/app-tests/e2e/test-auth-flow.spec.ts
@@ -12,7 +12,7 @@ test.describe.configure({ mode: 'serial' });
const username = 'Test User';
const email = 'test-user@auth-flow.documenso.com';
-const password = 'Password123';
+const password = 'Password123#';
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
await page.goto('/signup');
diff --git a/packages/email/template-components/template-document-invite.tsx b/packages/email/template-components/template-document-invite.tsx
index 216a3183d..b958e9029 100644
--- a/packages/email/template-components/template-document-invite.tsx
+++ b/packages/email/template-components/template-document-invite.tsx
@@ -1,3 +1,6 @@
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import type { RecipientRole } from '@documenso/prisma/client';
+
import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
@@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
documentName: string;
signDocumentLink: string;
assetBaseUrl: string;
+ role: RecipientRole;
}
export const TemplateDocumentInvite = ({
@@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
documentName,
signDocumentLink,
assetBaseUrl,
+ role,
}: TemplateDocumentInviteProps) => {
+ const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
+
return (
<>
- {inviterName} has invited you to sign
+ {inviterName} has invited you to {actionVerb.toLowerCase()}
"{documentName}"
- Continue by signing the document.
+ Continue by {progressiveVerb.toLowerCase()} the document.
@@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
- Sign Document
+ {actionVerb} Document
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx
index d6a45d5fc..d3bceb872 100644
--- a/packages/email/templates/document-invite.tsx
+++ b/packages/email/templates/document-invite.tsx
@@ -1,3 +1,5 @@
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
+import type { RecipientRole } from '@documenso/prisma/client';
import config from '@documenso/tailwind-config';
import {
@@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial & {
customBody?: string;
+ role: RecipientRole;
};
export const DocumentInviteEmailTemplate = ({
@@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({
signDocumentLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002',
customBody,
+ role,
}: DocumentInviteEmailTemplateProps) => {
- const previewText = `${inviterName} has invited you to sign ${documentName}`;
+ const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
+
+ const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
@@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({
documentName={documentName}
signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl}
+ role={role}
/>
@@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({
{customBody ? (
{customBody}
) : (
- `${inviterName} has invited you to sign the document "${documentName}".`
+ `${inviterName} has invited you to ${action} the document "${documentName}".`
)}
diff --git a/packages/lib/client-only/recipient-type.ts b/packages/lib/client-only/recipient-type.ts
index 8b5a8a528..44993796a 100644
--- a/packages/lib/client-only/recipient-type.ts
+++ b/packages/lib/client-only/recipient-type.ts
@@ -1,10 +1,10 @@
import type { Recipient } from '@documenso/prisma/client';
-import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
+import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => {
if (
- recipient.sendStatus === SendStatus.SENT &&
- recipient.signingStatus === SigningStatus.SIGNED
+ recipient.role === RecipientRole.CC ||
+ (recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
) {
return 'completed';
}
diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts
index 837ca3e3a..1918e2db0 100644
--- a/packages/lib/constants/auth.ts
+++ b/packages/lib/constants/auth.ts
@@ -1,4 +1,4 @@
-import { IdentityProvider } from '@documenso/prisma/client';
+import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
export const SALT_ROUNDS = 12;
@@ -10,3 +10,16 @@ export const IDENTITY_PROVIDER_NAME: { [key in IdentityProvider]: string } = {
export const IS_GOOGLE_SSO_ENABLED = Boolean(
process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID && process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET,
);
+
+export const USER_SECURITY_AUDIT_LOG_MAP: { [key in UserSecurityAuditLogType]: string } = {
+ [UserSecurityAuditLogType.ACCOUNT_SSO_LINK]: 'Linked account to SSO',
+ [UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE]: 'Profile updated',
+ [UserSecurityAuditLogType.AUTH_2FA_DISABLE]: '2FA Disabled',
+ [UserSecurityAuditLogType.AUTH_2FA_ENABLE]: '2FA Enabled',
+ [UserSecurityAuditLogType.PASSWORD_RESET]: 'Password reset',
+ [UserSecurityAuditLogType.PASSWORD_UPDATE]: 'Password updated',
+ [UserSecurityAuditLogType.SIGN_OUT]: 'Signed Out',
+ [UserSecurityAuditLogType.SIGN_IN]: 'Signed In',
+ [UserSecurityAuditLogType.SIGN_IN_FAIL]: 'Sign in attempt failed',
+ [UserSecurityAuditLogType.SIGN_IN_2FA_FAIL]: 'Sign in 2FA attempt failed',
+};
diff --git a/packages/lib/constants/crypto.ts b/packages/lib/constants/crypto.ts
index 40d3ef113..102e9d5d4 100644
--- a/packages/lib/constants/crypto.ts
+++ b/packages/lib/constants/crypto.ts
@@ -2,14 +2,16 @@ export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
export const DOCUMENSO_ENCRYPTION_SECONDARY_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY;
-if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
- throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
-}
+if (typeof window === 'undefined') {
+ if (!DOCUMENSO_ENCRYPTION_KEY || !DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
+ throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY or DOCUMENSO_ENCRYPTION_SECONDARY_KEY keys');
+ }
-if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
- throw new Error(
- 'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
- );
+ if (DOCUMENSO_ENCRYPTION_KEY === DOCUMENSO_ENCRYPTION_SECONDARY_KEY) {
+ throw new Error(
+ 'DOCUMENSO_ENCRYPTION_KEY and DOCUMENSO_ENCRYPTION_SECONDARY_KEY cannot be equal',
+ );
+ }
}
if (DOCUMENSO_ENCRYPTION_KEY === 'CAFEBABE') {
diff --git a/packages/lib/constants/recipient-roles.ts b/packages/lib/constants/recipient-roles.ts
new file mode 100644
index 000000000..920cf1f32
--- /dev/null
+++ b/packages/lib/constants/recipient-roles.ts
@@ -0,0 +1,26 @@
+import { RecipientRole } from '@documenso/prisma/client';
+
+export const RECIPIENT_ROLES_DESCRIPTION: {
+ [key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
+} = {
+ [RecipientRole.APPROVER]: {
+ actionVerb: 'Approve',
+ progressiveVerb: 'Approving',
+ roleName: 'Approver',
+ },
+ [RecipientRole.CC]: {
+ actionVerb: 'CC',
+ progressiveVerb: 'CC',
+ roleName: 'CC',
+ },
+ [RecipientRole.SIGNER]: {
+ actionVerb: 'Sign',
+ progressiveVerb: 'Signing',
+ roleName: 'Signer',
+ },
+ [RecipientRole.VIEWER]: {
+ actionVerb: 'View',
+ progressiveVerb: 'Viewing',
+ roleName: 'Viewer',
+ },
+};
diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts
index 50240174c..f23295a81 100644
--- a/packages/lib/next-auth/auth-options.ts
+++ b/packages/lib/next-auth/auth-options.ts
@@ -9,11 +9,12 @@ import type { GoogleProfile } from 'next-auth/providers/google';
import GoogleProvider from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma';
-import { IdentityProvider } from '@documenso/prisma/client';
+import { IdentityProvider, UserSecurityAuditLogType } from '@documenso/prisma/client';
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 { extractNextAuthRequestMetadata } from '../universal/extract-request-metadata';
import { ErrorCode } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = {
@@ -35,7 +36,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
},
backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' },
},
- authorize: async (credentials, _req) => {
+ authorize: async (credentials, req) => {
if (!credentials) {
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
}
@@ -51,8 +52,18 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}
const isPasswordsSame = await compare(password, user.password);
+ const requestMetadata = extractNextAuthRequestMetadata(req);
if (!isPasswordsSame) {
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId: user.id,
+ ipAddress: requestMetadata.ipAddress,
+ userAgent: requestMetadata.userAgent,
+ type: UserSecurityAuditLogType.SIGN_IN_FAIL,
+ },
+ });
+
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
}
@@ -62,6 +73,15 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user });
if (!isValid) {
+ await prisma.userSecurityAuditLog.create({
+ data: {
+ userId: user.id,
+ ipAddress: requestMetadata.ipAddress,
+ userAgent: requestMetadata.userAgent,
+ type: UserSecurityAuditLogType.SIGN_IN_2FA_FAIL,
+ },
+ });
+
throw new Error(
totpCode
? ErrorCode.INCORRECT_TWO_FACTOR_CODE
@@ -192,4 +212,5 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
return true;
},
},
+ // Note: `events` are handled in `apps/web/src/pages/api/auth/[...nextauth].ts` to allow access to the request.
};
diff --git a/packages/lib/server-only/2fa/disable-2fa.ts b/packages/lib/server-only/2fa/disable-2fa.ts
index 5b27d5c9d..dd8a180c9 100644
--- a/packages/lib/server-only/2fa/disable-2fa.ts
+++ b/packages/lib/server-only/2fa/disable-2fa.ts
@@ -1,21 +1,25 @@
import { compare } from 'bcrypt';
import { prisma } from '@documenso/prisma';
-import { User } from '@documenso/prisma/client';
+import type { User } from '@documenso/prisma/client';
+import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes';
+import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa';
type DisableTwoFactorAuthenticationOptions = {
user: User;
backupCode: string;
password: string;
+ requestMetadata?: RequestMetadata;
};
export const disableTwoFactorAuthentication = async ({
backupCode,
user,
password,
+ requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => {
if (!user.password) {
throw new Error(ErrorCode.USER_MISSING_PASSWORD);
@@ -33,15 +37,26 @@ export const disableTwoFactorAuthentication = async ({
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE);
}
- await prisma.user.update({
- where: {
- id: user.id,
- },
- data: {
- twoFactorEnabled: false,
- twoFactorBackupCodes: null,
- twoFactorSecret: null,
- },
+ await prisma.$transaction(async (tx) => {
+ await tx.user.update({
+ where: {
+ id: user.id,
+ },
+ data: {
+ twoFactorEnabled: false,
+ twoFactorBackupCodes: null,
+ twoFactorSecret: null,
+ },
+ });
+
+ await tx.userSecurityAuditLog.create({
+ data: {
+ userId: user.id,
+ type: UserSecurityAuditLogType.AUTH_2FA_DISABLE,
+ userAgent: requestMetadata?.userAgent,
+ ipAddress: requestMetadata?.ipAddress,
+ },
+ });
});
return true;
diff --git a/packages/lib/server-only/2fa/enable-2fa.ts b/packages/lib/server-only/2fa/enable-2fa.ts
index 9f61e52a4..19a2b67c2 100644
--- a/packages/lib/server-only/2fa/enable-2fa.ts
+++ b/packages/lib/server-only/2fa/enable-2fa.ts
@@ -1,18 +1,21 @@
import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma';
-import { User } from '@documenso/prisma/client';
+import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client';
+import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
type EnableTwoFactorAuthenticationOptions = {
user: User;
code: string;
+ requestMetadata?: RequestMetadata;
};
export const enableTwoFactorAuthentication = async ({
user,
code,
+ requestMetadata,
}: EnableTwoFactorAuthenticationOptions) => {
if (user.identityProvider !== 'DOCUMENSO') {
throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER);
@@ -32,13 +35,24 @@ export const enableTwoFactorAuthentication = async ({
throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
- const updatedUser = await prisma.user.update({
- where: {
- id: user.id,
- },
- data: {
- twoFactorEnabled: true,
- },
+ const updatedUser = await prisma.$transaction(async (tx) => {
+ await tx.userSecurityAuditLog.create({
+ data: {
+ userId: user.id,
+ type: UserSecurityAuditLogType.AUTH_2FA_ENABLE,
+ userAgent: requestMetadata?.userAgent,
+ ipAddress: requestMetadata?.ipAddress,
+ },
+ });
+
+ return await tx.user.update({
+ where: {
+ id: user.id,
+ },
+ data: {
+ twoFactorEnabled: true,
+ },
+ });
});
const recoveryCodes = getBackupCodes({ user: updatedUser });
diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts
index 30ddf0ec3..23f213574 100644
--- a/packages/lib/server-only/2fa/setup-2fa.ts
+++ b/packages/lib/server-only/2fa/setup-2fa.ts
@@ -5,7 +5,7 @@ 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 { type User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto';
diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts
index 2929c515b..8d367dbe4 100644
--- a/packages/lib/server-only/document/find-documents.ts
+++ b/packages/lib/server-only/document/find-documents.ts
@@ -3,7 +3,7 @@ import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Prisma } from '@documenso/prisma/client';
-import { SigningStatus } from '@documenso/prisma/client';
+import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { FindResultSet } from '../../types/find-result-set';
@@ -87,6 +87,9 @@ export const findDocuments = async ({
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
+ role: {
+ not: RecipientRole.CC,
+ },
},
},
deletedAt: null,
@@ -109,6 +112,9 @@ export const findDocuments = async ({
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
+ role: {
+ not: RecipientRole.CC,
+ },
},
},
deletedAt: null,
diff --git a/packages/lib/server-only/document/resend-document.tsx b/packages/lib/server-only/document/resend-document.tsx
index da4ffcb58..4c7b66be8 100644
--- a/packages/lib/server-only/document/resend-document.tsx
+++ b/packages/lib/server-only/document/resend-document.tsx
@@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
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';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
+
+import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type ResendDocumentOptions = {
documentId: number;
@@ -59,6 +61,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
await Promise.all(
document.Recipient.map(async (recipient) => {
+ if (recipient.role === RecipientRole.CC) {
+ return;
+ }
+
const { email, name } = recipient;
const customEmailTemplate = {
@@ -77,8 +83,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
+ role: recipient.role,
});
+ const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
+
await mailer.sendMail({
to: {
address: email,
@@ -90,7 +99,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
- : 'Please sign this document',
+ : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
diff --git a/packages/lib/server-only/document/seal-document.ts b/packages/lib/server-only/document/seal-document.ts
index 5fa4b1a00..b24288c3e 100644
--- a/packages/lib/server-only/document/seal-document.ts
+++ b/packages/lib/server-only/document/seal-document.ts
@@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { prisma } from '@documenso/prisma';
-import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
+import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
import { getFile } from '../../universal/upload/get-file';
@@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
const recipients = await prisma.recipient.findMany({
where: {
documentId: document.id,
+ role: {
+ not: RecipientRole.CC,
+ },
},
});
diff --git a/packages/lib/server-only/document/send-document.tsx b/packages/lib/server-only/document/send-document.tsx
index 25dc132ba..82b37852b 100644
--- a/packages/lib/server-only/document/send-document.tsx
+++ b/packages/lib/server-only/document/send-document.tsx
@@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
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, SendStatus } from '@documenso/prisma/client';
+import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
+
+import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type SendDocumentOptions = {
documentId: number;
@@ -47,6 +49,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
await Promise.all(
document.Recipient.map(async (recipient) => {
+ if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
+ return;
+ }
+
const { email, name } = recipient;
const customEmailTemplate = {
@@ -55,10 +61,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
'document.name': document.title,
};
- if (recipient.sendStatus === SendStatus.SENT) {
- return;
- }
-
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
@@ -69,8 +71,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
assetBaseUrl,
signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
+ role: recipient.role,
});
+ const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
+
await mailer.sendMail({
to: {
address: email,
@@ -82,7 +87,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
},
subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
- : 'Please sign this document',
+ : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template),
text: render(template, { plainText: true }),
});
diff --git a/packages/lib/server-only/recipient/set-recipients-for-document.ts b/packages/lib/server-only/recipient/set-recipients-for-document.ts
index 198f79be1..4917b213d 100644
--- a/packages/lib/server-only/recipient/set-recipients-for-document.ts
+++ b/packages/lib/server-only/recipient/set-recipients-for-document.ts
@@ -1,4 +1,5 @@
import { prisma } from '@documenso/prisma';
+import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id';
@@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions {
id?: number | null;
email: string;
name: string;
+ role: RecipientRole;
}[];
}
@@ -79,13 +81,20 @@ export const setRecipientsForDocument = async ({
update: {
name: recipient.name,
email: recipient.email,
+ role: recipient.role,
documentId,
+ signingStatus:
+ recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
create: {
name: recipient.name,
email: recipient.email,
+ role: recipient.role,
token: nanoid(),
documentId,
+ sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
+ signingStatus:
+ recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
},
}),
),
diff --git a/packages/lib/server-only/user/find-user-security-audit-logs.ts b/packages/lib/server-only/user/find-user-security-audit-logs.ts
new file mode 100644
index 000000000..0d6b5c8d5
--- /dev/null
+++ b/packages/lib/server-only/user/find-user-security-audit-logs.ts
@@ -0,0 +1,52 @@
+import type { FindResultSet } from '@documenso/lib/types/find-result-set';
+import { prisma } from '@documenso/prisma';
+import type { UserSecurityAuditLog, UserSecurityAuditLogType } from '@documenso/prisma/client';
+
+export type FindUserSecurityAuditLogsOptions = {
+ userId: number;
+ type?: UserSecurityAuditLogType;
+ page?: number;
+ perPage?: number;
+ orderBy?: {
+ column: keyof Omit;
+ direction: 'asc' | 'desc';
+ };
+};
+
+export const findUserSecurityAuditLogs = async ({
+ userId,
+ type,
+ page = 1,
+ perPage = 10,
+ orderBy,
+}: FindUserSecurityAuditLogsOptions) => {
+ const orderByColumn = orderBy?.column ?? 'createdAt';
+ const orderByDirection = orderBy?.direction ?? 'desc';
+
+ const whereClause = {
+ userId,
+ type,
+ };
+
+ const [data, count] = await Promise.all([
+ prisma.userSecurityAuditLog.findMany({
+ where: whereClause,
+ skip: Math.max(page - 1, 0) * perPage,
+ take: perPage,
+ orderBy: {
+ [orderByColumn]: orderByDirection,
+ },
+ }),
+ prisma.userSecurityAuditLog.count({
+ where: whereClause,
+ }),
+ ]);
+
+ return {
+ data,
+ count,
+ currentPage: Math.max(page, 1),
+ perPage,
+ totalPages: Math.ceil(count / perPage),
+ } satisfies FindResultSet;
+};
diff --git a/packages/lib/server-only/user/reset-password.ts b/packages/lib/server-only/user/reset-password.ts
index 2233894d8..39aac5d28 100644
--- a/packages/lib/server-only/user/reset-password.ts
+++ b/packages/lib/server-only/user/reset-password.ts
@@ -1,16 +1,19 @@
import { compare, hash } from 'bcrypt';
import { prisma } from '@documenso/prisma';
+import { UserSecurityAuditLogType } from '@documenso/prisma/client';
import { SALT_ROUNDS } from '../../constants/auth';
+import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { sendResetPassword } from '../auth/send-reset-password';
export type ResetPasswordOptions = {
token: string;
password: string;
+ requestMetadata?: RequestMetadata;
};
-export const resetPassword = async ({ token, password }: ResetPasswordOptions) => {
+export const resetPassword = async ({ token, password, requestMetadata }: ResetPasswordOptions) => {
if (!token) {
throw new Error('Invalid token provided. Please try again.');
}
@@ -56,6 +59,14 @@ export const resetPassword = async ({ token, password }: ResetPasswordOptions) =
userId: foundToken.userId,
},
}),
+ prisma.userSecurityAuditLog.create({
+ data: {
+ userId: foundToken.userId,
+ type: UserSecurityAuditLogType.PASSWORD_RESET,
+ userAgent: requestMetadata?.userAgent,
+ ipAddress: requestMetadata?.ipAddress,
+ },
+ }),
]);
await sendResetPassword({ userId: foundToken.userId });
diff --git a/packages/lib/server-only/user/update-password.ts b/packages/lib/server-only/user/update-password.ts
index b7579cd35..2621fe8e3 100644
--- a/packages/lib/server-only/user/update-password.ts
+++ b/packages/lib/server-only/user/update-password.ts
@@ -1,19 +1,22 @@
import { compare, hash } from 'bcrypt';
+import { SALT_ROUNDS } from '@documenso/lib/constants/auth';
+import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
-
-import { SALT_ROUNDS } from '../../constants/auth';
+import { UserSecurityAuditLogType } from '@documenso/prisma/client';
export type UpdatePasswordOptions = {
userId: number;
password: string;
currentPassword: string;
+ requestMetadata?: RequestMetadata;
};
export const updatePassword = async ({
userId,
password,
currentPassword,
+ requestMetadata,
}: UpdatePasswordOptions) => {
// Existence check
const user = await prisma.user.findFirstOrThrow({
@@ -39,14 +42,23 @@ export const updatePassword = async ({
const hashedNewPassword = await hash(password, SALT_ROUNDS);
- const updatedUser = await prisma.user.update({
- where: {
- id: userId,
- },
- data: {
- password: hashedNewPassword,
- },
- });
+ return await prisma.$transaction(async (tx) => {
+ await tx.userSecurityAuditLog.create({
+ data: {
+ userId,
+ type: UserSecurityAuditLogType.PASSWORD_UPDATE,
+ userAgent: requestMetadata?.userAgent,
+ ipAddress: requestMetadata?.ipAddress,
+ },
+ });
- return updatedUser;
+ return await tx.user.update({
+ where: {
+ id: userId,
+ },
+ data: {
+ password: hashedNewPassword,
+ },
+ });
+ });
};
diff --git a/packages/lib/server-only/user/update-profile.ts b/packages/lib/server-only/user/update-profile.ts
index a28fd21c5..a99caff99 100644
--- a/packages/lib/server-only/user/update-profile.ts
+++ b/packages/lib/server-only/user/update-profile.ts
@@ -1,12 +1,21 @@
import { prisma } from '@documenso/prisma';
+import { UserSecurityAuditLogType } from '@documenso/prisma/client';
+
+import type { RequestMetadata } from '../../universal/extract-request-metadata';
export type UpdateProfileOptions = {
userId: number;
name: string;
signature: string;
+ requestMetadata?: RequestMetadata;
};
-export const updateProfile = async ({ userId, name, signature }: UpdateProfileOptions) => {
+export const updateProfile = async ({
+ userId,
+ name,
+ signature,
+ requestMetadata,
+}: UpdateProfileOptions) => {
// Existence check
await prisma.user.findFirstOrThrow({
where: {
@@ -14,15 +23,24 @@ export const updateProfile = async ({ userId, name, signature }: UpdateProfileOp
},
});
- const updatedUser = await prisma.user.update({
- where: {
- id: userId,
- },
- data: {
- name,
- signature,
- },
- });
+ return await prisma.$transaction(async (tx) => {
+ await tx.userSecurityAuditLog.create({
+ data: {
+ userId,
+ type: UserSecurityAuditLogType.ACCOUNT_PROFILE_UPDATE,
+ userAgent: requestMetadata?.userAgent,
+ ipAddress: requestMetadata?.ipAddress,
+ },
+ });
- return updatedUser;
+ return await tx.user.update({
+ where: {
+ id: userId,
+ },
+ data: {
+ name,
+ signature,
+ },
+ });
+ });
};
diff --git a/packages/lib/types/search-params.ts b/packages/lib/types/search-params.ts
new file mode 100644
index 000000000..ff3fdc4e2
--- /dev/null
+++ b/packages/lib/types/search-params.ts
@@ -0,0 +1,20 @@
+import { z } from 'zod';
+
+export const ZBaseTableSearchParamsSchema = z.object({
+ query: z
+ .string()
+ .optional()
+ .catch(() => undefined),
+ page: z.coerce
+ .number()
+ .min(1)
+ .optional()
+ .catch(() => undefined),
+ perPage: z.coerce
+ .number()
+ .min(1)
+ .optional()
+ .catch(() => undefined),
+});
+
+export type TBaseTableSearchParamsSchema = z.infer;
diff --git a/packages/lib/universal/extract-request-metadata.ts b/packages/lib/universal/extract-request-metadata.ts
new file mode 100644
index 000000000..5549e5de7
--- /dev/null
+++ b/packages/lib/universal/extract-request-metadata.ts
@@ -0,0 +1,37 @@
+import type { NextApiRequest } from 'next';
+
+import type { RequestInternal } from 'next-auth';
+import { z } from 'zod';
+
+const ZIpSchema = z.string().ip();
+
+export type RequestMetadata = {
+ ipAddress?: string;
+ userAgent?: string;
+};
+
+export const extractNextApiRequestMetadata = (req: NextApiRequest): RequestMetadata => {
+ const parsedIp = ZIpSchema.safeParse(req.headers['x-forwarded-for'] || req.socket.remoteAddress);
+
+ const ipAddress = parsedIp.success ? parsedIp.data : undefined;
+ const userAgent = req.headers['user-agent'];
+
+ return {
+ ipAddress,
+ userAgent,
+ };
+};
+
+export const extractNextAuthRequestMetadata = (
+ req: Pick,
+): RequestMetadata => {
+ const parsedIp = ZIpSchema.safeParse(req.headers?.['x-forwarded-for']);
+
+ const ipAddress = parsedIp.success ? parsedIp.data : undefined;
+ const userAgent = req.headers?.['user-agent'];
+
+ return {
+ ipAddress,
+ userAgent,
+ };
+};
diff --git a/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql b/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql
new file mode 100644
index 000000000..441132300
--- /dev/null
+++ b/packages/prisma/migrations/20231202220928_add_recipient_roles/migration.sql
@@ -0,0 +1,5 @@
+-- CreateEnum
+CREATE TYPE "RecipientRole" AS ENUM ('CC', 'SIGNER', 'VIEWER', 'APPROVER');
+
+-- AlterTable
+ALTER TABLE "Recipient" ADD COLUMN "role" "RecipientRole" NOT NULL DEFAULT 'SIGNER';
diff --git a/packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql b/packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql
new file mode 100644
index 000000000..491012380
--- /dev/null
+++ b/packages/prisma/migrations/20240131004516_add_user_security_audit_logs/migration.sql
@@ -0,0 +1,17 @@
+-- CreateEnum
+CREATE TYPE "UserSecurityAuditLogType" AS ENUM ('ACCOUNT_PROFILE_UPDATE', 'ACCOUNT_SSO_LINK', 'AUTH_2FA_DISABLE', 'AUTH_2FA_ENABLE', 'PASSWORD_RESET', 'PASSWORD_UPDATE', 'SIGN_OUT', 'SIGN_IN', 'SIGN_IN_FAIL', 'SIGN_IN_2FA_FAIL');
+
+-- CreateTable
+CREATE TABLE "UserSecurityAuditLog" (
+ "id" SERIAL NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "type" "UserSecurityAuditLogType" NOT NULL,
+ "userAgent" TEXT,
+ "ipAddress" TEXT,
+
+ CONSTRAINT "UserSecurityAuditLog_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "UserSecurityAuditLog" ADD CONSTRAINT "UserSecurityAuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index e1549e072..87d29d6b2 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -40,12 +40,38 @@ model User {
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String?
- VerificationToken VerificationToken[]
- Template Template[]
+
+ VerificationToken VerificationToken[]
+ Template Template[]
+ securityAuditLogs UserSecurityAuditLog[]
@@index([email])
}
+enum UserSecurityAuditLogType {
+ ACCOUNT_PROFILE_UPDATE
+ ACCOUNT_SSO_LINK
+ AUTH_2FA_DISABLE
+ AUTH_2FA_ENABLE
+ PASSWORD_RESET
+ PASSWORD_UPDATE
+ SIGN_OUT
+ SIGN_IN
+ SIGN_IN_FAIL
+ SIGN_IN_2FA_FAIL
+}
+
+model UserSecurityAuditLog {
+ id Int @id @default(autoincrement())
+ userId Int
+ createdAt DateTime @default(now())
+ type UserSecurityAuditLogType
+ userAgent String?
+ ipAddress String?
+
+ User User @relation(fields: [userId], references: [id], onDelete: Cascade)
+}
+
model PasswordResetToken {
id Int @id @default(autoincrement())
token String @unique
@@ -161,9 +187,9 @@ model DocumentMeta {
id String @id @default(cuid())
subject String?
message String?
- timezone String? @db.Text @default("Etc/UTC")
- password String?
- dateFormat String? @db.Text @default("yyyy-MM-dd hh:mm a")
+ timezone String? @default("Etc/UTC") @db.Text
+ password String?
+ dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
}
@@ -183,20 +209,28 @@ enum SigningStatus {
SIGNED
}
+enum RecipientRole {
+ CC
+ SIGNER
+ VIEWER
+ APPROVER
+}
+
model Recipient {
- id Int @id @default(autoincrement())
+ id Int @id @default(autoincrement())
documentId Int?
templateId Int?
- email String @db.VarChar(255)
- name String @default("") @db.VarChar(255)
+ email String @db.VarChar(255)
+ name String @default("") @db.VarChar(255)
token String
expired DateTime?
signedAt DateTime?
+ role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT)
- Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
- Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
+ Document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
+ Template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
Field Field[]
Signature Signature[]
@@ -280,10 +314,10 @@ model Template {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
- templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
- User User @relation(fields: [userId], references: [id], onDelete: Cascade)
- Recipient Recipient[]
- Field Field[]
+ templateDocumentData DocumentData @relation(fields: [templateDocumentDataId], references: [id], onDelete: Cascade)
+ User User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ Recipient Recipient[]
+ Field Field[]
@@unique([templateDocumentDataId])
}
diff --git a/packages/trpc/server/context.ts b/packages/trpc/server/context.ts
index e1973f08b..7136afd70 100644
--- a/packages/trpc/server/context.ts
+++ b/packages/trpc/server/context.ts
@@ -1,4 +1,4 @@
-import { CreateNextContextOptions } from '@trpc/server/adapters/next';
+import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getServerSession } from '@documenso/lib/next-auth/get-server-session';
@@ -9,6 +9,7 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
return {
session: null,
user: null,
+ req,
};
}
@@ -16,12 +17,14 @@ export const createTrpcContext = async ({ req, res }: CreateNextContextOptions)
return {
session: null,
user: null,
+ req,
};
}
return {
session,
user,
+ req,
};
};
diff --git a/packages/trpc/server/document-router/schema.ts b/packages/trpc/server/document-router/schema.ts
index c4389bdfb..5d8c23c27 100644
--- a/packages/trpc/server/document-router/schema.ts
+++ b/packages/trpc/server/document-router/schema.ts
@@ -1,6 +1,6 @@
import { z } from 'zod';
-import { DocumentStatus, FieldType } from '@documenso/prisma/client';
+import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1),
@@ -35,6 +35,7 @@ export const ZSetRecipientsForDocumentMutationSchema = z.object({
id: z.number().nullish(),
email: z.string().min(1).email(),
name: z.string(),
+ role: z.nativeEnum(RecipientRole),
}),
),
});
diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts
index 4dcf4ca93..4a0d47345 100644
--- a/packages/trpc/server/profile-router/router.ts
+++ b/packages/trpc/server/profile-router/router.ts
@@ -1,15 +1,18 @@
import { TRPCError } from '@trpc/server';
+import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
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 { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import {
ZConfirmEmailMutationSchema,
+ ZFindUserSecurityAuditLogsSchema,
ZForgotPasswordFormSchema,
ZResetPasswordFormSchema,
ZRetrieveUserByIdQuerySchema,
@@ -18,6 +21,22 @@ import {
} from './schema';
export const profileRouter = router({
+ findUserSecurityAuditLogs: authenticatedProcedure
+ .input(ZFindUserSecurityAuditLogsSchema)
+ .query(async ({ input, ctx }) => {
+ try {
+ return await findUserSecurityAuditLogs({
+ userId: ctx.user.id,
+ ...input,
+ });
+ } catch (err) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'We were unable to find user security audit logs. Please try again.',
+ });
+ }
+ }),
+
getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => {
try {
const { id } = input;
@@ -41,6 +60,7 @@ export const profileRouter = router({
userId: ctx.user.id,
name,
signature,
+ requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
console.error(err);
@@ -63,6 +83,7 @@ export const profileRouter = router({
userId: ctx.user.id,
password,
currentPassword,
+ requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
let message =
@@ -91,13 +112,14 @@ export const profileRouter = router({
}
}),
- resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input }) => {
+ resetPassword: procedure.input(ZResetPasswordFormSchema).mutation(async ({ input, ctx }) => {
try {
const { password, token } = input;
return await resetPassword({
token,
password,
+ requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
let message = 'We were unable to reset your password. Please try again.';
diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts
index 1d6820007..522b13552 100644
--- a/packages/trpc/server/profile-router/schema.ts
+++ b/packages/trpc/server/profile-router/schema.ts
@@ -2,6 +2,11 @@ import { z } from 'zod';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema';
+export const ZFindUserSecurityAuditLogsSchema = z.object({
+ page: z.number().optional(),
+ perPage: z.number().optional(),
+});
+
export const ZRetrieveUserByIdQuerySchema = z.object({
id: z.number().min(1),
});
@@ -29,6 +34,7 @@ export const ZConfirmEmailMutationSchema = z.object({
email: z.string().email().min(1),
});
+export type TFindUserSecurityAuditLogsSchema = z.infer;
export type TRetrieveUserByIdQuerySchema = z.infer;
export type TUpdateProfileMutationSchema = z.infer;
export type TUpdatePasswordMutationSchema = z.infer;
diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts
index 09097895c..1ada3d0d3 100644
--- a/packages/trpc/server/recipient-router/router.ts
+++ b/packages/trpc/server/recipient-router/router.ts
@@ -25,6 +25,7 @@ export const recipientRouter = router({
id: signer.nativeId,
email: signer.email,
name: signer.name,
+ role: signer.role,
})),
});
} catch (err) {
diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts
index 8920e7672..a6b4e0d11 100644
--- a/packages/trpc/server/recipient-router/schema.ts
+++ b/packages/trpc/server/recipient-router/schema.ts
@@ -1,5 +1,7 @@
import { z } from 'zod';
+import { RecipientRole } from '@documenso/prisma/client';
+
export const ZAddSignersMutationSchema = z
.object({
documentId: z.number(),
@@ -8,6 +10,7 @@ export const ZAddSignersMutationSchema = z
nativeId: z.number().optional(),
email: z.string().email().min(1),
name: z.string(),
+ role: z.nativeEnum(RecipientRole),
}),
),
})
diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts
index e18f4cb4a..28e919e92 100644
--- a/packages/trpc/server/template-router/router.ts
+++ b/packages/trpc/server/template-router/router.ts
@@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server';
+import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
@@ -41,6 +42,12 @@ export const templateRouter = router({
try {
const { templateId } = input;
+ const limits = await getServerLimits({ email: ctx.user.email });
+
+ if (limits.remaining.documents === 0) {
+ throw new Error('You have reached your document limit.');
+ }
+
return await createDocumentFromTemplate({
templateId,
userId: ctx.user.id,
diff --git a/packages/trpc/server/two-factor-authentication-router/router.ts b/packages/trpc/server/two-factor-authentication-router/router.ts
index a10f7a543..36fe93a60 100644
--- a/packages/trpc/server/two-factor-authentication-router/router.ts
+++ b/packages/trpc/server/two-factor-authentication-router/router.ts
@@ -6,6 +6,7 @@ import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/en
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 { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { authenticatedProcedure, router } from '../trpc';
import {
@@ -23,7 +24,10 @@ export const twoFactorAuthenticationRouter = router({
const { password } = input;
- return await setupTwoFactorAuthentication({ user, password });
+ return await setupTwoFactorAuthentication({
+ user,
+ password,
+ });
}),
enable: authenticatedProcedure
@@ -34,7 +38,11 @@ export const twoFactorAuthenticationRouter = router({
const { code } = input;
- return await enableTwoFactorAuthentication({ user, code });
+ return await enableTwoFactorAuthentication({
+ user,
+ code,
+ requestMetadata: extractNextApiRequestMetadata(ctx.req),
+ });
} catch (err) {
console.error(err);
@@ -53,7 +61,12 @@ export const twoFactorAuthenticationRouter = router({
const { password, backupCode } = input;
- return await disableTwoFactorAuthentication({ user, password, backupCode });
+ return await disableTwoFactorAuthentication({
+ user,
+ password,
+ backupCode,
+ requestMetadata: extractNextApiRequestMetadata(ctx.req),
+ });
} catch (err) {
console.error(err);
diff --git a/packages/ui/primitives/alert.tsx b/packages/ui/primitives/alert.tsx
index 190f7781d..092fbb2b4 100644
--- a/packages/ui/primitives/alert.tsx
+++ b/packages/ui/primitives/alert.tsx
@@ -1,21 +1,33 @@
import * as React from 'react';
-import { VariantProps, cva } from 'class-variance-authority';
+import type { VariantProps } from 'class-variance-authority';
+import { cva } from 'class-variance-authority';
import { cn } from '../lib/utils';
const alertVariants = cva(
- 'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11',
+ 'relative w-full rounded-lg p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&>svg~*]:pl-8',
{
variants: {
variant: {
- default: 'bg-background text-foreground',
- destructive:
- 'text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive',
+ default:
+ 'bg-green-50 text-green-700 [&_.alert-title]:text-green-800 [&>svg]:text-green-400',
+ neutral:
+ 'bg-gray-50 dark:bg-neutral-900/20 text-muted-foreground [&_.alert-title]:text-foreground',
+ secondary: 'bg-blue-50 text-blue-700 [&_.alert-title]:text-blue-800 [&>svg]:text-blue-400',
+ destructive: 'bg-red-50 text-red-700 [&_.alert-title]:text-red-800 [&>svg]:text-red-400',
+ warning:
+ 'bg-yellow-50 text-yellow-700 [&_.alert-title]:text-yellow-800 [&>svg]:text-yellow-400',
+ },
+ padding: {
+ tighter: 'p-2',
+ tight: 'px-4 py-2',
+ default: 'p-4',
},
},
defaultVariants: {
variant: 'default',
+ padding: 'default',
},
},
);
@@ -23,19 +35,20 @@ const alertVariants = cva(
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes & VariantProps
->(({ className, variant, ...props }, ref) => (
-
+>(({ className, variant, padding, ...props }, ref) => (
+
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef>(
({ className, ...props }, ref) => (
-
+
),
);
@@ -45,7 +58,7 @@ const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
-
+
));
AlertDescription.displayName = 'AlertDescription';
diff --git a/packages/ui/primitives/data-table.tsx b/packages/ui/primitives/data-table.tsx
index e4a89e141..9cc14a684 100644
--- a/packages/ui/primitives/data-table.tsx
+++ b/packages/ui/primitives/data-table.tsx
@@ -2,36 +2,53 @@
import React, { useMemo } from 'react';
-import {
+import type {
ColumnDef,
PaginationState,
Table as TTable,
Updater,
- flexRender,
- getCoreRowModel,
- useReactTable,
+ VisibilityState,
} from '@tanstack/react-table';
+import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
+import { Skeleton } from './skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './table';
export type DataTableChildren = (_table: TTable) => React.ReactNode;
export interface DataTableProps {
columns: ColumnDef[];
+ columnVisibility?: VisibilityState;
data: TData[];
perPage?: number;
currentPage?: number;
totalPages?: number;
onPaginationChange?: (_page: number, _perPage: number) => void;
+ onClearFilters?: () => void;
+ hasFilters?: boolean;
children?: DataTableChildren;
+ skeleton?: {
+ enable: boolean;
+ rows: number;
+ component?: React.ReactNode;
+ };
+ error?: {
+ enable: boolean;
+ component?: React.ReactNode;
+ };
}
export function DataTable({
columns,
+ columnVisibility,
data,
+ error,
perPage,
currentPage,
totalPages,
+ skeleton,
+ hasFilters,
+ onClearFilters,
onPaginationChange,
children,
}: DataTableProps) {
@@ -67,6 +84,7 @@ export function DataTable({
getCoreRowModel: getCoreRowModel(),
state: {
pagination: manualPagination ? pagination : undefined,
+ columnVisibility,
},
manualPagination,
pageCount: totalPages,
@@ -103,10 +121,31 @@ export function DataTable({
))}
))
+ ) : error?.enable ? (
+
+ {error.component ?? (
+
+ Something went wrong.
+
+ )}
+
+ ) : skeleton?.enable ? (
+ Array.from({ length: skeleton.rows }).map((_, i) => (
+ {skeleton.component ?? }
+ ))
) : (
-
- No results.
+
+ No results found
+
+ {hasFilters && onClearFilters !== undefined && (
+ onClearFilters()}
+ className="text-foreground mt-1 text-sm"
+ >
+ Clear filters
+
+ )}
)}
diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx
index a8ae9f0e3..74764df80 100644
--- a/packages/ui/primitives/document-flow/add-fields.tsx
+++ b/packages/ui/primitives/document-flow/add-fields.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Caveat } from 'next/font/google';
@@ -10,8 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
+import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
+import { RecipientRole } from '@documenso/prisma/client';
import { FieldType, SendStatus } from '@documenso/prisma/client';
import { cn } from '../../lib/utils';
@@ -30,8 +32,7 @@ import {
DocumentFlowFormContainerStep,
} from './document-flow-root';
import { FieldItem } from './field-item';
-import type { DocumentFlowStep } from './types';
-import { FRIENDLY_FIELD_TYPE } from './types';
+import { type DocumentFlowStep, FRIENDLY_FIELD_TYPE } from './types';
const fontCaveat = Caveat({
weight: ['500'],
@@ -103,6 +104,12 @@ export const AddFieldsFormPartial = ({
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
+ const isFieldsDisabled =
+ !selectedSigner ||
+ hasSelectedSignerBeenSent ||
+ selectedSigner?.role === RecipientRole.VIEWER ||
+ selectedSigner?.role === RecipientRole.CC;
+
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
const [coords, setCoords] = useState({
x: 0,
@@ -282,12 +289,28 @@ export const AddFieldsFormPartial = ({
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
}, [recipients]);
+ const recipientsByRole = useMemo(() => {
+ const recipientsByRole: Record = {
+ CC: [],
+ VIEWER: [],
+ SIGNER: [],
+ APPROVER: [],
+ };
+
+ recipients.forEach((recipient) => {
+ recipientsByRole[recipient.role].push(recipient);
+ });
+
+ return recipientsByRole;
+ }, [recipients]);
+
return (
<>
+
{selectedField && (
@@ -352,72 +375,94 @@ export const AddFieldsFormPartial = ({
+
No recipient matching this description was found.
-
- {recipients.map((recipient, index) => (
- {
- setSelectedSigner(recipient);
- setShowRecipientsSelector(false);
- }}
- >
- {recipient.sendStatus !== SendStatus.SENT ? (
-
- ) : (
-
-
-
-
-
- This document has already been sent to this recipient. You can no
- longer edit this recipient.
-
-
- )}
+ {Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => (
+
+
+ {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName
+ }
+
- {recipient.name && (
+ {recipients.length === 0 && (
+
+ No recipients with this role
+
+ )}
+
+ {recipients.map((recipient) => (
+ {
+ setSelectedSigner(recipient);
+ setShowRecipientsSelector(false);
+ }}
+ >
- {recipient.name} ({recipient.email})
-
- )}
+ {recipient.name && (
+
+ {recipient.name} ({recipient.email})
+
+ )}
- {!recipient.name && (
-
- {recipient.email}
+ {!recipient.name && (
+ {recipient.email}
+ )}
- )}
-
- ))}
-
+
+
+ {recipient.sendStatus !== SendStatus.SENT ? (
+
+ ) : (
+
+
+
+
+
+
+ This document has already been sent to this recipient. You can no
+ longer edit this recipient.
+
+
+ )}
+
+
+ ))}
+
+ ))}
)}
-
+
setSelectedField(FieldType.SIGNATURE)}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
@@ -441,7 +486,6 @@ export const AddFieldsFormPartial = ({
setSelectedField(FieldType.EMAIL)}
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
@@ -464,7 +508,6 @@ export const AddFieldsFormPartial = ({
setSelectedField(FieldType.NAME)}
onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined}
@@ -487,7 +530,6 @@ export const AddFieldsFormPartial = ({
setSelectedField(FieldType.DATE)}
onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined}
@@ -506,7 +548,7 @@ export const AddFieldsFormPartial = ({
-
+
diff --git a/packages/ui/primitives/document-flow/add-signers.tsx b/packages/ui/primitives/document-flow/add-signers.tsx
index 71be1c069..26aedcae7 100644
--- a/packages/ui/primitives/document-flow/add-signers.tsx
+++ b/packages/ui/primitives/document-flow/add-signers.tsx
@@ -4,19 +4,20 @@ import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion';
-import { Plus, Trash } from 'lucide-react';
+import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client';
-import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
+import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { Button } from '../button';
import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input';
import { Label } from '../label';
+import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper';
import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types';
@@ -28,8 +29,16 @@ import {
DocumentFlowFormContainerHeader,
DocumentFlowFormContainerStep,
} from './document-flow-root';
+import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types';
+const ROLE_ICONS: Record = {
+ SIGNER: ,
+ APPROVER: ,
+ CC: ,
+ VIEWER: ,
+};
+
export type AddSignersFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
@@ -42,7 +51,7 @@ export const AddSignersFormPartial = ({
documentFlow,
recipients,
document,
- fields: _fields,
+ fields,
onSubmit,
}: AddSignersFormProps) => {
const { toast } = useToast();
@@ -66,12 +75,14 @@ export const AddSignersFormPartial = ({
formId: String(recipient.id),
name: recipient.name,
email: recipient.email,
+ role: recipient.role,
}))
: [
{
formId: initialId,
name: '',
email: '',
+ role: RecipientRole.SIGNER,
},
],
},
@@ -103,6 +114,7 @@ export const AddSignersFormPartial = ({
formId: nanoid(12),
name: '',
email: '',
+ role: RecipientRole.SIGNER,
});
};
@@ -136,6 +148,10 @@ export const AddSignersFormPartial = ({
/>
+ {fields.map((field, index) => (
+
+ ))}
+
{signers.map((signer, index) => (
+
+
(
+ onChange(x)}>
+ {ROLE_ICONS[value]}
+
+
+
+
+ {ROLE_ICONS[RecipientRole.SIGNER]}
+ Signer
+
+
+
+
+
+ {ROLE_ICONS[RecipientRole.CC]}
+ Receives copy
+
+
+
+
+
+ {ROLE_ICONS[RecipientRole.APPROVER]}
+ Approver
+
+
+
+
+
+ {ROLE_ICONS[RecipientRole.VIEWER]}
+ Viewer
+
+
+
+
+ )}
+ />
+
+
({
defaultValues: {
@@ -98,6 +98,10 @@ export const AddSubjectFormPartial = ({
/>
+ {fields.map((field, index) => (
+
+ ))}
+
@@ -106,7 +110,6 @@ export const AddSubjectFormPartial = ({
{
@@ -55,6 +56,10 @@ export const AddTitleFormPartial = ({
description={documentFlow.description}
/>
+ {fields.map((field, index) => (
+
+ ))}
+
diff --git a/packages/ui/primitives/document-flow/show-field-item.tsx b/packages/ui/primitives/document-flow/show-field-item.tsx
new file mode 100644
index 000000000..4e4a0dc99
--- /dev/null
+++ b/packages/ui/primitives/document-flow/show-field-item.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import type { Prisma } from '@prisma/client';
+import { createPortal } from 'react-dom';
+
+import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
+
+import { cn } from '../../lib/utils';
+import { Card, CardContent } from '../card';
+import { FRIENDLY_FIELD_TYPE } from './types';
+
+export type ShowFieldItemProps = {
+ field: Prisma.FieldGetPayload
;
+ recipients: Prisma.RecipientGetPayload[];
+};
+
+export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
+ const coords = useFieldPageCoords(field);
+
+ const signerEmail =
+ recipients.find((recipient) => recipient.id === field.recipientId)?.email ?? '';
+
+ return createPortal(
+
+
+
+ {FRIENDLY_FIELD_TYPE[field.type]}
+
+
+ {signerEmail}
+
+
+
+
,
+ document.body,
+ );
+};
diff --git a/packages/ui/primitives/signature-pad/signature-pad.tsx b/packages/ui/primitives/signature-pad/signature-pad.tsx
index 80bac0e18..eb9403df4 100644
--- a/packages/ui/primitives/signature-pad/signature-pad.tsx
+++ b/packages/ui/primitives/signature-pad/signature-pad.tsx
@@ -16,6 +16,7 @@ const DPI = 2;
export type SignaturePadProps = Omit, 'onChange'> & {
onChange?: (_signatureDataUrl: string | null) => void;
containerClassName?: string;
+ disabled?: boolean;
};
export const SignaturePad = ({
@@ -23,6 +24,7 @@ export const SignaturePad = ({
containerClassName,
defaultValue,
onChange,
+ disabled = false,
...props
}: SignaturePadProps) => {
const $el = useRef(null);
@@ -214,7 +216,11 @@ export const SignaturePad = ({
}, [defaultValue]);
return (
-