diff --git a/apps/marketing/content/blog/linear-gh.mdx b/apps/marketing/content/blog/linear-gh.mdx index 27b1ae208..1267931d6 100644 --- a/apps/marketing/content/blog/linear-gh.mdx +++ b/apps/marketing/content/blog/linear-gh.mdx @@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog. While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead. We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live). -Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :) +Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :) Best from Hamburg\ Timur diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 389528bf8..a1b56257a 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -158,6 +158,7 @@ export const SinglePlayerClient = () => { readStatus: 'OPENED', signingStatus: 'NOT_SIGNED', sendStatus: 'NOT_SENT', + role: 'SIGNER', }; const onFileDrop = async (file: File) => { diff --git a/apps/marketing/src/components/(marketing)/widget.tsx b/apps/marketing/src/components/(marketing)/widget.tsx index 80c13b275..d4305a04c 100644 --- a/apps/marketing/src/components/(marketing)/widget.tsx +++ b/apps/marketing/src/components/(marketing)/widget.tsx @@ -399,6 +399,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => { diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx index 9910ef111..ecddf1190 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-button.tsx @@ -2,13 +2,13 @@ import Link from 'next/link'; -import { Download, Edit, Pencil } from 'lucide-react'; +import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { match } from 'ts-pattern'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { Button } from '@documenso/ui/primitives/button'; @@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + const role = recipient?.role; const onDownloadClick = async () => { try { @@ -68,6 +69,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { } }; + // TODO: Consider if want to keep this logic for hiding viewing for CC'ers + if (recipient?.role === RecipientRole.CC && isComplete === false) { + return null; + } + return match({ isOwner, isRecipient, @@ -87,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( )) .with({ isPending: true, isSigned: true }, () => ( )) .with({ isComplete: true }, () => ( diff --git a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx index f14321b35..e1d9b64bb 100644 --- a/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/documents/data-table-action-dropdown.tsx @@ -5,9 +5,11 @@ import { useState } from 'react'; import Link from 'next/link'; import { + CheckCircle, Copy, Download, Edit, + EyeIcon, Loader, MoreHorizontal, Pencil, @@ -19,7 +21,7 @@ import { useSession } from 'next-auth/react'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import type { Document, Recipient, User } from '@documenso/prisma/client'; -import { DocumentStatus } from '@documenso/prisma/client'; +import { DocumentStatus, RecipientRole } from '@documenso/prisma/client'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc as trpcClient } from '@documenso/trpc/client'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; @@ -105,12 +107,32 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Action - - - - Sign - - + {recipient?.role !== RecipientRole.CC && ( + + + {recipient?.role === RecipientRole.VIEWER && ( + <> + + View + + )} + + {recipient?.role === RecipientRole.SIGNER && ( + <> + + Sign + + )} + + {recipient?.role === RecipientRole.APPROVER && ( + <> + + Approve + + )} + + + )} diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index e61aad649..5780df1dc 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -74,7 +74,7 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage

Documents

- + {[ ExtendedDocumentStatus.INBOX, diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx new file mode 100644 index 000000000..6e183b0c7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/activity/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { UserSecurityActivityDataTable } from './user-security-activity-data-table'; + +export const metadata: Metadata = { + title: 'Security activity', +}; + +export default function SettingsSecurityActivityPage() { + return ( +
+

Security activity

+ +

+ View all recent security activity related to your account. +

+ +
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx b/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx new file mode 100644 index 000000000..4937749fc --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/activity/user-security-activity-data-table.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +import type { DateTimeFormatOptions } from 'luxon'; +import { DateTime } from 'luxon'; +import { UAParser } from 'ua-parser-js'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { USER_SECURITY_AUDIT_LOG_MAP } from '@documenso/lib/constants/auth'; +import { ZBaseTableSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; + +import { LocaleDate } from '~/components/formatter/locale-date'; + +const dateFormat: DateTimeFormatOptions = { + ...DateTime.DATETIME_SHORT, + hourCycle: 'h12', +}; + +export const UserSecurityActivityDataTable = () => { + const parser = new UAParser(); + + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZBaseTableSearchParamsSchema.parse( + Object.fromEntries(searchParams ?? []), + ); + + const { data, isLoading, isInitialLoading, isLoadingError } = + trpc.profile.findUserSecurityAuditLogs.useQuery( + { + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }, + { + keepPreviousData: true, + }, + ); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 10, + currentPage: 1, + totalPages: 1, + }; + + return ( + , + }, + { + header: 'Device', + cell: ({ row }) => { + if (!row.original.userAgent) { + return 'N/A'; + } + + parser.setUA(row.original.userAgent); + + const result = parser.getResult(); + + let output = result.os.name; + + if (!output) { + return 'N/A'; + } + + if (result.os.version) { + output += ` (${result.os.version})`; + } + + return output; + }, + }, + { + header: 'Browser', + cell: ({ row }) => { + if (!row.original.userAgent) { + return 'N/A'; + } + + parser.setUA(row.original.userAgent); + + const result = parser.getResult(); + + return result.browser.name ?? 'N/A'; + }, + }, + { + header: 'IP Address', + accessorKey: 'ipAddress', + cell: ({ row }) => row.original.ipAddress ?? 'N/A', + }, + { + header: 'Action', + accessorKey: 'type', + cell: ({ row }) => USER_SECURITY_AUDIT_LOG_MAP[row.original.type], + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + hasFilters={parsedSearchParams.page !== undefined || parsedSearchParams.perPage !== undefined} + onClearFilters={() => router.push(pathname ?? '/')} + error={{ + enable: isLoadingError, + }} + skeleton={{ + enable: isLoading && isInitialLoading, + rows: 3, + component: ( + <> + + + + + + + + + + + + + + + + + ), + }} + > + {(table) => } + + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index 854ba66ce..4e0a40838 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -1,7 +1,10 @@ import type { Metadata } from 'next'; +import Link from 'next/link'; import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes'; @@ -26,43 +29,74 @@ export default async function SecuritySettingsPage() { {user.identityProvider === 'DOCUMENSO' ? (
- + -
+
-

Two Factor Authentication

+ +
+ Two factor authentication -

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

- -
-
Two-factor methods
+ + Create one-time passwords that serve as a secondary authentication method for + confirming your identity when requested during the sign-in process. + +
-
+
{user.twoFactorEnabled && ( -
-
Recovery methods
+ +
+ Recovery codes + + + Two factor authentication recovery codes are used to access your account in the + event that you lose access to your authenticator app. + +
-
+ )}
) : ( -
-

+ + Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]} -

-

+ + + To update your password, enable two-factor authentication, and manage other security settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account settings. -

-
+ + )} + + +
+ Recent activity + + + View all recent security activity related to your account. + +
+ + +
); } diff --git a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx index 63d6888b1..7930dcd0e 100644 --- a/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx +++ b/apps/web/src/app/(dashboard)/templates/data-table-templates.tsx @@ -2,13 +2,16 @@ import { useState, useTransition } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { Loader, Plus } from 'lucide-react'; +import { AlertTriangle, Loader, Plus } from 'lucide-react'; +import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import type { Template } from '@documenso/prisma/client'; import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; @@ -36,6 +39,8 @@ export const TemplatesDataTable = ({ const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); + const { remaining } = useLimits(); + const router = useRouter(); const { toast } = useToast(); @@ -77,6 +82,19 @@ export const TemplatesDataTable = ({ return (
+ {remaining.documents === 0 && ( + + + Document Limit Exceeded! + + You have reached your document limit.{' '} + + Upgrade your account to continue! + + + + )} + - setFullName(e.target.value.trimStart())} - /> + +
+ + ) : ( + <> +

+ Please review the document before signing. +

-
- +
- - - { - setSignature(value); - }} +
+
+
+ + + setFullName(e.target.value.trimStart())} /> - - +
+ +
+ + + + + { + setSignature(value); + }} + /> + + +
+
+ +
+ + + +
-
- -
- - - -
- + + )} diff --git a/apps/web/src/app/(signing)/sign/[token]/page.tsx b/apps/web/src/app/(signing)/sign/[token]/page.tsx index 004c59329..7e025593c 100644 --- a/apps/web/src/app/(signing)/sign/[token]/page.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/page.tsx @@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; -import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; +import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp

- {document.User.name} ({document.User.email}) has invited you to sign this document. + {document.User.name} ({document.User.email}) has invited you to{' '} + {recipient.role === RecipientRole.VIEWER && 'view'} + {recipient.role === RecipientRole.SIGNER && 'sign'} + {recipient.role === RecipientRole.APPROVER && 'approve'} this document.

diff --git a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx index 1e86e99bc..a9aedbc3d 100644 --- a/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/sign-dialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import type { Document, Field } from '@documenso/prisma/client'; +import { RecipientRole } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -17,6 +18,7 @@ export type SignDialogProps = { fields: Field[]; fieldsValidated: () => void | Promise; onSignatureComplete: () => void | Promise; + role: RecipientRole; }; export const SignDialog = ({ @@ -25,6 +27,7 @@ export const SignDialog = ({ fields, fieldsValidated, onSignatureComplete, + role, }: SignDialogProps) => { const [showDialog, setShowDialog] = useState(false); const truncatedTitle = truncateTitle(document.title); @@ -45,9 +48,18 @@ export const SignDialog = ({
-
Sign Document
+
+ {role === RecipientRole.VIEWER && 'Mark Document as Viewed'} + {role === RecipientRole.SIGNER && 'Sign Document'} + {role === RecipientRole.APPROVER && 'Approve Document'} +
- You are about to finish signing "{truncatedTitle}". Are you sure? + {role === RecipientRole.VIEWER && + `You are about to finish viewing "${truncatedTitle}". Are you sure?`} + {role === RecipientRole.SIGNER && + `You are about to finish signing "${truncatedTitle}". Are you sure?`} + {role === RecipientRole.APPROVER && + `You are about to finish approving "${truncatedTitle}". Are you sure?`}
@@ -71,7 +83,9 @@ export const SignDialog = ({ loading={isSubmitting} onClick={onSignatureComplete} > - Sign + {role === RecipientRole.VIEWER && 'Mark as Viewed'} + {role === RecipientRole.SIGNER && 'Sign'} + {role === RecipientRole.APPROVER && 'Approve'} diff --git a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx index d04b3a998..46182c36e 100644 --- a/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx +++ b/apps/web/src/components/(dashboard)/avatar/avatar-with-recipient.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; import { cn } from '@documenso/ui/lib/utils'; @@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) { type={getRecipientType(recipient)} fallbackText={recipientAbbreviation(recipient)} /> - - {recipient.email} +
+
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

+
+
); } diff --git a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx index 7429d8ee5..bd7bea2b0 100644 --- a/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx +++ b/apps/web/src/components/(dashboard)/avatar/stack-avatars-with-tooltip.tsx @@ -1,4 +1,5 @@ import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; +import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import type { Recipient } from '@documenso/prisma/client'; import { @@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({ type={getRecipientType(recipient)} fallbackText={recipientAbbreviation(recipient)} /> - {recipient.email} +
+

{recipient.email}

+

+ {RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName} +

+
))} diff --git a/apps/web/src/components/forms/2fa/authenticator-app.tsx b/apps/web/src/components/forms/2fa/authenticator-app.tsx index 1d164bd22..316272e34 100644 --- a/apps/web/src/components/forms/2fa/authenticator-app.tsx +++ b/apps/web/src/components/forms/2fa/authenticator-app.tsx @@ -19,27 +19,14 @@ export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) return ( <> -
-
-

Authenticator app

- -

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

-
- -
- {isTwoFactorEnabled ? ( - - ) : ( - - )} -
+
+ {isTwoFactorEnabled ? ( + + ) : ( + + )}
-
- @@ -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 = ({ )} /> -
- -
+ ); @@ -251,15 +252,15 @@ export const EnableAuthenticatorAppDialog = ({ )} /> -
- -
+ )) 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. -

-
- -
- -
-
+ -
- -
+ ); 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) => { /> -
+
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) = )} /> )} + + + + + + - -
- - - -
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 && ( + + )}
)} 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. + + + )} +
+
+ ))} +
+ ))}
)}
-
+
-
+
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) => (
+
+ ( + + )} + /> +
+