diff --git a/apps/web/package.json b/apps/web/package.json index 89675318f..fd4faa0c1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,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" }, @@ -53,7 +54,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" }, "overrides": { "next-auth": { 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/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/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/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/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/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/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/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/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..353a855ae 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) } @@ -184,19 +210,19 @@ enum SigningStatus { } 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? 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 +306,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/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/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 && ( + + )}
)}