diff --git a/.env.example b/.env.example index 9250ab9cf..f21560ca3 100644 --- a/.env.example +++ b/.env.example @@ -25,7 +25,7 @@ NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/ # [[E2E Tests]] E2E_TEST_AUTHENTICATE_USERNAME="Test User" E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" -E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password" +E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123" # [[STORAGE]] # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 diff --git a/apps/web/src/app/(dashboard)/documents/page.tsx b/apps/web/src/app/(dashboard)/documents/page.tsx index a15d65306..e61aad649 100644 --- a/apps/web/src/app/(dashboard)/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/documents/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { getStats } from '@documenso/lib/server-only/document/get-stats'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; @@ -9,7 +10,6 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; -import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types'; import { DocumentStatus } from '~/components/formatter/document-status'; @@ -32,15 +32,16 @@ export const metadata: Metadata = { export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { const { user } = await getRequiredServerComponentSession(); - const stats = await getStats({ - user, - }); - const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : ''; const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; + const stats = await getStats({ + user, + period, + }); + const results = await findDocuments({ userId: user.id, status, diff --git a/apps/web/src/components/(dashboard)/period-selector/types.ts b/apps/web/src/components/(dashboard)/period-selector/types.ts index 2b50f5d6c..8ae1c5fbe 100644 --- a/apps/web/src/components/(dashboard)/period-selector/types.ts +++ b/apps/web/src/components/(dashboard)/period-selector/types.ts @@ -1,4 +1,4 @@ -export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; +import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; export const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions diff --git a/apps/web/src/components/forms/password.tsx b/apps/web/src/components/forms/password.tsx index 0eb491537..0fa5ad462 100644 --- a/apps/web/src/components/forms/password.tsx +++ b/apps/web/src/components/forms/password.tsx @@ -7,6 +7,7 @@ import { z } from 'zod'; import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; +import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -22,18 +23,9 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZPasswordFormSchema = z .object({ - currentPassword: z - .string() - .min(6, { message: 'Password should contain at least 6 characters' }) - .max(72, { message: 'Password should not contain more than 72 characters' }), - password: z - .string() - .min(6, { message: 'Password should contain at least 6 characters' }) - .max(72, { message: 'Password should not contain more than 72 characters' }), - repeatedPassword: z - .string() - .min(6, { message: 'Password should contain at least 6 characters' }) - .max(72, { message: 'Password should not contain more than 72 characters' }), + currentPassword: ZCurrentPasswordSchema, + password: ZPasswordSchema, + repeatedPassword: ZPasswordSchema, }) .refine((data) => data.password === data.repeatedPassword, { message: 'Passwords do not match', diff --git a/apps/web/src/components/forms/reset-password.tsx b/apps/web/src/components/forms/reset-password.tsx index 354584f6e..03608a27d 100644 --- a/apps/web/src/components/forms/reset-password.tsx +++ b/apps/web/src/components/forms/reset-password.tsx @@ -8,6 +8,7 @@ import { z } from 'zod'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; +import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -23,8 +24,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; export const ZResetPasswordFormSchema = z .object({ - password: z.string().min(6).max(72), - repeatedPassword: z.string().min(6).max(72), + password: ZPasswordSchema, + repeatedPassword: ZPasswordSchema, }) .refine((data) => data.password === data.repeatedPassword, { path: ['repeatedPassword'], diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index 038f9fe68..17bb2c57c 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -9,6 +9,7 @@ import { FcGoogle } from 'react-icons/fc'; import { z } from 'zod'; 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'; @@ -39,7 +40,7 @@ const LOGIN_REDIRECT_PATH = '/documents'; export const ZSignInFormSchema = z.object({ email: z.string().email().min(1), - password: z.string().min(6).max(72), + password: ZCurrentPasswordSchema, totpCode: z.string().trim().optional(), backupCode: z.string().trim().optional(), }); diff --git a/apps/web/src/components/forms/signup.tsx b/apps/web/src/components/forms/signup.tsx index 3f2723ec8..ebfbf72c9 100644 --- a/apps/web/src/components/forms/signup.tsx +++ b/apps/web/src/components/forms/signup.tsx @@ -9,6 +9,7 @@ import { z } from 'zod'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { TRPCClientError } from '@documenso/trpc/client'; import { trpc } from '@documenso/trpc/react'; +import { ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -26,15 +27,22 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; const SIGN_UP_REDIRECT_PATH = '/documents'; -export const ZSignUpFormSchema = z.object({ - name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), - email: z.string().email().min(1), - password: z - .string() - .min(6, { message: 'Password should contain at least 6 characters' }) - .max(72, { message: 'Password should not contain more than 72 characters' }), - signature: z.string().min(1, { message: 'We need your signature to sign documents' }), -}); +export const ZSignUpFormSchema = z + .object({ + name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), + email: z.string().email().min(1), + password: ZPasswordSchema, + signature: z.string().min(1, { message: 'We need your signature to sign documents' }), + }) + .refine( + (data) => { + const { name, email, password } = data; + return !password.includes(name) && !password.includes(email.split('@')[0]); + }, + { + message: 'Password should not be common or based on personal information', + }, + ); export type TSignUpFormSchema = z.infer; diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index def85f2d4..2929c515b 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -9,6 +9,8 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen import type { FindResultSet } from '../../types/find-result-set'; import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; +export type PeriodSelectorValue = '' | '7d' | '14d' | '30d'; + export type FindDocumentsOptions = { userId: number; term?: string; @@ -19,7 +21,7 @@ export type FindDocumentsOptions = { column: keyof Omit; direction: 'asc' | 'desc'; }; - period?: '' | '7d' | '14d' | '30d'; + period?: PeriodSelectorValue; }; export const findDocuments = async ({ diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 044d9a2dc..6aaa9a596 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,14 +1,31 @@ +import { DateTime } from 'luxon'; + import { prisma } from '@documenso/prisma'; -import type { User } from '@documenso/prisma/client'; +import type { Prisma, User } from '@documenso/prisma/client'; import { SigningStatus } from '@documenso/prisma/client'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import type { PeriodSelectorValue } from './find-documents'; + export type GetStatsInput = { user: User; + period?: PeriodSelectorValue; }; -export const getStats = async ({ user }: GetStatsInput) => { +export const getStats = async ({ user, period }: GetStatsInput) => { + let createdAt: Prisma.DocumentWhereInput['createdAt']; + + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + + createdAt = { + gte: startOfPeriod.toJSDate(), + }; + } + const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([ prisma.document.groupBy({ by: ['status'], @@ -17,6 +34,7 @@ export const getStats = async ({ user }: GetStatsInput) => { }, where: { userId: user.id, + createdAt, deletedAt: null, }, }), @@ -33,6 +51,7 @@ export const getStats = async ({ user }: GetStatsInput) => { signingStatus: SigningStatus.NOT_SIGNED, }, }, + createdAt, deletedAt: null, }, }), @@ -42,6 +61,7 @@ export const getStats = async ({ user }: GetStatsInput) => { _all: true, }, where: { + createdAt, User: { email: { not: user.email, diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index cc969c679..dbe42a25c 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -1,9 +1,25 @@ import { z } from 'zod'; +export const ZCurrentPasswordSchema = z + .string() + .min(6, { message: 'Must be at least 6 characters in length' }) + .max(72); + +export const ZPasswordSchema = z + .string() + .regex(new RegExp('.*[A-Z].*'), { message: 'One uppercase character' }) + .regex(new RegExp('.*[a-z].*'), { message: 'One lowercase character' }) + .regex(new RegExp('.*\\d.*'), { message: 'One number' }) + .regex(new RegExp('.*[`~<>?,./!@#$%^&*()\\-_+="\'|{}\\[\\];:\\\\].*'), { + message: 'One special character is required', + }) + .min(8, { message: 'Must be at least 8 characters in length' }) + .max(72, { message: 'Cannot be more than 72 characters in length' }); + export const ZSignUpMutationSchema = z.object({ name: z.string().min(1), email: z.string().email(), - password: z.string().min(6), + password: ZPasswordSchema, signature: z.string().min(1, { message: 'A signature is required.' }), }); diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index ef9ca2a14..1d6820007 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { ZCurrentPasswordSchema, ZPasswordSchema } from '../auth-router/schema'; + export const ZRetrieveUserByIdQuerySchema = z.object({ id: z.number().min(1), }); @@ -10,8 +12,8 @@ export const ZUpdateProfileMutationSchema = z.object({ }); export const ZUpdatePasswordMutationSchema = z.object({ - currentPassword: z.string().min(6), - password: z.string().min(6), + currentPassword: ZCurrentPasswordSchema, + password: ZPasswordSchema, }); export const ZForgotPasswordFormSchema = z.object({ @@ -19,7 +21,7 @@ export const ZForgotPasswordFormSchema = z.object({ }); export const ZResetPasswordFormSchema = z.object({ - password: z.string().min(6), + password: ZPasswordSchema, token: z.string().min(1), }); diff --git a/packages/ui/primitives/data-table-pagination.tsx b/packages/ui/primitives/data-table-pagination.tsx index 8147c92fb..feebf6c54 100644 --- a/packages/ui/primitives/data-table-pagination.tsx +++ b/packages/ui/primitives/data-table-pagination.tsx @@ -1,4 +1,4 @@ -import { Table } from '@tanstack/react-table'; +import type { Table } from '@tanstack/react-table'; import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; import { match } from 'ts-pattern';