From 773566f19384921b98f720fb8222a87f17d87a20 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 18 Sep 2023 22:33:07 +1000 Subject: [PATCH 01/48] feat: add free tier Stripe subscription --- .env.example | 1 + apps/marketing/process-env.d.ts | 1 + apps/web/process-env.d.ts | 1 + .../billing/billing-portal-button.tsx | 54 +++++++++ .../billing/create-billing-portal.action.ts | 80 ++++++++++++++ .../app/(dashboard)/settings/billing/page.tsx | 103 ++++++++++-------- .../web/src/pages/api/stripe/webhook/index.ts | 40 +++++++ .../ee/server-only/stripe/get-customer.ts | 19 ++++ .../migration.sql | 17 +++ packages/prisma/schema.prisma | 19 ++-- packages/tsconfig/process-env.d.ts | 1 + packages/ui/primitives/button.tsx | 4 +- turbo.json | 16 ++- 13 files changed, 298 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts create mode 100644 packages/ee/server-only/stripe/get-customer.ts create mode 100644 packages/prisma/migrations/20230918111438_update_subscription_constraints_and_columns/migration.sql diff --git a/.env.example b/.env.example index 6f32b5a63..4450ef310 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,7 @@ NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID= +NEXT_PUBLIC_STRIPE_FREE_PLAN_ID= # [[FEATURES]] # OPTIONAL: Leave blank to disable PostHog and feature flags. diff --git a/apps/marketing/process-env.d.ts b/apps/marketing/process-env.d.ts index ac170a616..53b126e57 100644 --- a/apps/marketing/process-env.d.ts +++ b/apps/marketing/process-env.d.ts @@ -6,6 +6,7 @@ declare namespace NodeJS { NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; + NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string; NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; diff --git a/apps/web/process-env.d.ts b/apps/web/process-env.d.ts index 1cb0018ac..dfada0e57 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -6,6 +6,7 @@ declare namespace NodeJS { NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; + NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string; NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx new file mode 100644 index 000000000..994f7c221 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@documenso/ui/primitives/button'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { createBillingPortal } from './create-billing-portal.action'; + +export default function BillingPortalButton() { + const { toast } = useToast(); + + const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false); + + const handleFetchPortalUrl = async () => { + if (isFetchingPortalUrl) { + return; + } + + setIsFetchingPortalUrl(true); + + try { + const sessionUrl = await createBillingPortal(); + if (!sessionUrl) { + throw new Error('NO_SESSION'); + } + + window.open(sessionUrl, '_blank'); + } catch (e) { + let description = + 'We are unable to proceed to the billing portal at this time. Please try again, or contact support.'; + + if (e.message === 'CUSTOMER_NOT_FOUND') { + description = + 'You do not currently have a customer record, this should not happen. Please contact support for assistance.'; + } + + toast({ + title: 'Something went wrong', + description, + variant: 'destructive', + duration: 10000, + }); + } + + setIsFetchingPortalUrl(false); + }; + + return ( + + ); +} diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts new file mode 100644 index 000000000..f235cb846 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -0,0 +1,80 @@ +'use server'; + +import { + getStripeCustomerByEmail, + getStripeCustomerById, +} from '@documenso/ee/server-only/stripe/get-customer'; +import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; +import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; +import { prisma } from '@documenso/prisma'; + +export const createBillingPortal = async () => { + const user = await getRequiredServerComponentSession(); + + const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); + + let stripeCustomer: Stripe.Customer | null = null; + + // Find the Stripe customer for the current user subscription. + if (existingSubscription) { + stripeCustomer = await getStripeCustomerById(existingSubscription.customerId); + + if (!stripeCustomer) { + throw new Error('Missing Stripe customer for subscription'); + } + } + + // Fallback to check if a Stripe customer already exists for the current user. + stripeCustomer = await getStripeCustomerByEmail(user.email); + + // Create a Stripe customer if it does not exist for the current user. + if (!stripeCustomer) { + stripeCustomer = await stripe.customers.create({ + name: user.name ?? undefined, + email: user.email, + metadata: { + userId: user.id, + }, + }); + } + + const stripeCustomerSubsriptions = stripeCustomer.subscriptions?.data ?? []; + + // Create a free subscription for user if it does not exist. + if (!existingSubscription && stripeCustomerSubsriptions.length === 0) { + const newSubscription = await stripe.subscriptions.create({ + customer: stripeCustomer.id, + items: [ + { + plan: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID, + }, + ], + }); + + await prisma.subscription.upsert({ + where: { + userId: user.id, + customerId: stripeCustomer.id, + }, + create: { + userId: user.id, + customerId: stripeCustomer.id, + planId: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID, + periodEnd: new Date(newSubscription.current_period_end * 1000), + status: 'ACTIVE', + }, + update: { + planId: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID, + periodEnd: new Date(newSubscription.current_period_end * 1000), + status: 'ACTIVE', + }, + }); + } + + return getPortalSession({ + customerId: stripeCustomer.id, + returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`, + }); +}; diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 555c645ce..bbc158fe0 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -1,16 +1,16 @@ -import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer'; -import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; +import { match } from 'ts-pattern'; + import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; -import { SubscriptionStatus } from '@documenso/prisma/client'; -import { Button } from '@documenso/ui/primitives/button'; import { LocaleDate } from '~/components/formatter/locale-date'; import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag'; +import BillingPortalButton from './billing-portal-button'; + export default async function BillingSettingsPage() { const user = await getRequiredServerComponentSession(); @@ -21,57 +21,74 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => { - if (sub) { - return sub; - } + const subscription = await getSubscriptionByUserId({ userId: user.id }); - // If we don't have a customer record, create one as well as an empty subscription. - return createCustomer({ user }); - }); + let subscriptionProduct: Stripe.Product | null = null; - let billingPortalUrl = ''; + if (subscription?.planId) { + const foundSubscriptionProduct = (await stripe.products.list()).data.find( + (item) => item.default_price === subscription.planId, + ); - if (subscription.customerId) { - billingPortalUrl = await getPortalSession({ - customerId: subscription.customerId, - returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`, - }); + subscriptionProduct = foundSubscriptionProduct ?? null; } + const isMissingOrInactiveOrFreePlan = + !subscription || + subscription.status === 'INACTIVE' || + subscription?.planId === process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID; + return (

Billing

-

- Your subscription is{' '} - {subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}. - {subscription?.periodEnd && ( - <> - {' '} - Your next payment is due on{' '} - - - - . - +

+ {isMissingOrInactiveOrFreePlan && ( +

+ You are currently on the Free Plan. +

)} -

+ + {!isMissingOrInactiveOrFreePlan && + match(subscription.status) + .with('ACTIVE', () => ( +

+ {subscriptionProduct ? ( + + You are currently subscribed to{' '} + {subscriptionProduct.name} + + ) : ( + You currently have an active plan + )} + {subscription.periodEnd && ( + + {' '} + which is set to{' '} + {subscription.cancelAtPeriodEnd ? ( + + end on{' '} + . + + ) : ( + + automatically renew on{' '} + . + + )} + + )} +

+ )) + .with('PAST_DUE', () => ( +

Your current plan is past due. Please update your payment information.

+ )) + .otherwise(() => null)} +

- {billingPortalUrl && ( - - )} - - {!billingPortalUrl && ( -

- You do not currently have a customer record, this should not happen. Please contact - support for assistance. -

- )} +
); } diff --git a/apps/web/src/pages/api/stripe/webhook/index.ts b/apps/web/src/pages/api/stripe/webhook/index.ts index 9efab2a78..06f63e293 100644 --- a/apps/web/src/pages/api/stripe/webhook/index.ts +++ b/apps/web/src/pages/api/stripe/webhook/index.ts @@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { randomBytes } from 'crypto'; import { readFileSync } from 'fs'; import { buffer } from 'micro'; +import { match } from 'ts-pattern'; import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; @@ -16,6 +17,7 @@ import { ReadStatus, SendStatus, SigningStatus, + SubscriptionStatus, } from '@documenso/prisma/client'; const log = (...args: unknown[]) => console.log('[stripe]', ...args); @@ -54,6 +56,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ); log('event-type:', event.type); + if (event.type === 'customer.subscription.updated') { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const subscription = event.data.object as Stripe.Subscription; + + await handleCustomerSubscriptionUpdated(subscription); + + return res.status(200).json({ + success: true, + message: 'Webhook received', + }); + } + if (event.type === 'checkout.session.completed') { // This is required since we don't want to create a guard for every event type // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -195,3 +209,29 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) message: 'Unhandled webhook event', }); } + +const handleCustomerSubscriptionUpdated = async (subscription: Stripe.Subscription) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const plan = (subscription as unknown as Stripe.SubscriptionItem).plan; + + const customerId = + typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; + + const status = match(subscription.status) + .with('active', () => SubscriptionStatus.ACTIVE) + .with('past_due', () => SubscriptionStatus.PAST_DUE) + .otherwise(() => SubscriptionStatus.INACTIVE); + + await prisma.subscription.update({ + where: { + customerId: customerId, + }, + data: { + planId: plan.id, + status, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + periodEnd: new Date(subscription.current_period_end * 1000), + updatedAt: new Date(), + }, + }); +}; diff --git a/packages/ee/server-only/stripe/get-customer.ts b/packages/ee/server-only/stripe/get-customer.ts new file mode 100644 index 000000000..11e782966 --- /dev/null +++ b/packages/ee/server-only/stripe/get-customer.ts @@ -0,0 +1,19 @@ +import { stripe } from '@documenso/lib/server-only/stripe'; + +export const getStripeCustomerByEmail = async (email: string) => { + const foundStripeCustomers = await stripe.customers.list({ + email, + }); + + return foundStripeCustomers.data[0] ?? null; +}; + +export const getStripeCustomerById = async (stripeCustomerId: string) => { + try { + const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId); + + return !stripeCustomer.deleted ? stripeCustomer : null; + } catch { + return null; + } +}; diff --git a/packages/prisma/migrations/20230918111438_update_subscription_constraints_and_columns/migration.sql b/packages/prisma/migrations/20230918111438_update_subscription_constraints_and_columns/migration.sql new file mode 100644 index 000000000..8125b0cb1 --- /dev/null +++ b/packages/prisma/migrations/20230918111438_update_subscription_constraints_and_columns/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - A unique constraint covering the columns `[userId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail. + - Made the column `customerId` on table `Subscription` required. This step will fail if there are existing NULL values in that column. + +*/ + +DELETE FROM "Subscription" +WHERE "customerId" IS NULL; + +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false, +ALTER COLUMN "customerId" SET NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 1ff3d7a75..72d54f4ca 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -41,15 +41,16 @@ enum SubscriptionStatus { } model Subscription { - id Int @id @default(autoincrement()) - status SubscriptionStatus @default(INACTIVE) - planId String? - priceId String? - customerId String? - periodEnd DateTime? - userId Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + status SubscriptionStatus @default(INACTIVE) + planId String? + priceId String? + customerId String + periodEnd DateTime? + userId Int @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + cancelAtPeriodEnd Boolean @default(false) User User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index b0852b4f4..19447957f 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -9,6 +9,7 @@ declare namespace NodeJS { NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; + NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string; NEXT_PRIVATE_STRIPE_API_KEY: string; NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string; diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index c67117d6f..31df69dee 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -56,14 +56,14 @@ export interface ButtonProps } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, loading, ...props }, ref) => { if (asChild) { return ( ); } - const showLoader = props.loading === true; + const showLoader = loading === true; const isDisabled = props.disabled || showLoader; return ( diff --git a/turbo.json b/turbo.json index a5b333c66..9fea252d0 100644 --- a/turbo.json +++ b/turbo.json @@ -2,8 +2,13 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**"] + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "!.next/cache/**" + ] }, "lint": {}, "dev": { @@ -11,7 +16,9 @@ "persistent": true } }, - "globalDependencies": ["**/.env.*local"], + "globalDependencies": [ + "**/.env.*local" + ], "globalEnv": [ "APP_VERSION", "NEXTAUTH_URL", @@ -23,6 +30,7 @@ "NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID", "NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID", + "NEXT_PUBLIC_STRIPE_FREE_PLAN_ID", "NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_NEXT_AUTH_SECRET", "NEXT_PRIVATE_GOOGLE_CLIENT_ID", @@ -50,4 +58,4 @@ "NEXT_PRIVATE_SMTP_FROM_ADDRESS", "NEXT_PRIVATE_STRIPE_API_KEY" ] -} +} \ No newline at end of file From 027a588604504f4a1b4bb4ed7b26f1dc1db72c6b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 18 Sep 2023 22:47:46 +1000 Subject: [PATCH 02/48] feat: wip --- .../settings/billing/create-billing-portal.action.ts | 4 ++-- apps/web/src/pages/api/stripe/webhook/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts index f235cb846..d42d1e97e 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -40,10 +40,10 @@ export const createBillingPortal = async () => { }); } - const stripeCustomerSubsriptions = stripeCustomer.subscriptions?.data ?? []; + const stripeCustomerSubscriptions = stripeCustomer.subscriptions?.data ?? []; // Create a free subscription for user if it does not exist. - if (!existingSubscription && stripeCustomerSubsriptions.length === 0) { + if (!existingSubscription && stripeCustomerSubscriptions.length === 0) { const newSubscription = await stripe.subscriptions.create({ customer: stripeCustomer.id, items: [ diff --git a/apps/web/src/pages/api/stripe/webhook/index.ts b/apps/web/src/pages/api/stripe/webhook/index.ts index 06f63e293..9f1a6937a 100644 --- a/apps/web/src/pages/api/stripe/webhook/index.ts +++ b/apps/web/src/pages/api/stripe/webhook/index.ts @@ -212,7 +212,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const handleCustomerSubscriptionUpdated = async (subscription: Stripe.Subscription) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const plan = (subscription as unknown as Stripe.SubscriptionItem).plan; + const { plan } = subscription as unknown as Stripe.SubscriptionItem; const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id; From cbe118b74fb4bbf42a27c57744f97f6a9c62b875 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 19 Sep 2023 15:14:47 +1000 Subject: [PATCH 03/48] fix: merge issues --- .../settings/billing/create-billing-portal.action.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts index d42d1e97e..98c3d2a96 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -75,6 +75,6 @@ export const createBillingPortal = async () => { return getPortalSession({ customerId: stripeCustomer.id, - returnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`, + returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, }); }; From 4d485940eaa01ac93d7fb49d508806af588182d3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 19 Sep 2023 15:30:58 +1000 Subject: [PATCH 04/48] fix: stripe customer fetch logic --- .../settings/billing/create-billing-portal.action.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts index 98c3d2a96..331943648 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -26,8 +26,10 @@ export const createBillingPortal = async () => { } } - // Fallback to check if a Stripe customer already exists for the current user. - stripeCustomer = await getStripeCustomerByEmail(user.email); + // Fallback to check if a Stripe customer already exists for the current user email. + if (!stripeCustomer) { + stripeCustomer = await getStripeCustomerByEmail(user.email); + } // Create a Stripe customer if it does not exist for the current user. if (!stripeCustomer) { From 775de16d0af38918e5559deb60da5288d3560ccb Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 21 Sep 2023 12:43:36 +0100 Subject: [PATCH 05/48] feat: admin ui for managing instance --- apps/web/src/app/(dashboard)/admin/nav.tsx | 8 +- .../app/(dashboard)/admin/users/[id]/page.tsx | 8 ++ .../admin/users/data-table-users.tsx | 135 ++++++++++++++++++ .../src/app/(dashboard)/admin/users/page.tsx | 35 +++++ .../lib/server-only/user/get-all-users.ts | 37 +++++ 5 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/users/page.tsx create mode 100644 packages/lib/server-only/user/get-all-users.ts diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx index 3b87a9b13..0b59335bf 100644 --- a/apps/web/src/app/(dashboard)/admin/nav.tsx +++ b/apps/web/src/app/(dashboard)/admin/nav.tsx @@ -37,10 +37,12 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => { 'justify-start md:w-full', pathname?.startsWith('/admin/users') && 'bg-secondary', )} - disabled + asChild > - - Users (Coming Soon) + + + Users + ); diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx new file mode 100644 index 000000000..3b8cfa287 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -0,0 +1,8 @@ +export default function UserPage() { + return ( +
+

Hey

+

Ho

+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx new file mode 100644 index 000000000..890d5cd48 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { useTransition } from 'react'; + +import Link from 'next/link'; + +import { Edit, Loader } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { Role } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; + +interface User { + id: number; + name: string | null; + email: string; + roles: Role[]; + Subscription: Subscription[]; +} + +interface Subscription { + id: number; + status: string; + planId: string | null; + priceId: string | null; + createdAt: Date | null; + periodEnd: Date | null; +} + +type UsersDataTableProps = { + users: User[]; + perPage: number; + page: number; + totalPages: number; +}; + +export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTableProps) => { + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + console.log(users); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + return ( +
+
{row.original.id}
, + }, + { + header: 'Name', + accessorKey: 'name', + cell: ({ row }) =>
{row.original.name}
, + }, + { + header: 'Email', + accessorKey: 'email', + cell: ({ row }) =>
{row.original.email}
, + }, + { + header: 'Roles', + accessorKey: 'roles', + cell: ({ row }) => { + return ( + <> + {row.original.roles.map((role: string, idx: number) => { + return ( + + {role} {} + + ); + })} + + ); + }, + }, + { + header: 'Subscription status', + accessorKey: 'subscription', + cell: ({ row }) => { + return ( + <> + {row.original.Subscription.map((subscription: Subscription, idx: number) => { + return {subscription.status}; + })} + + ); + }, + }, + { + header: 'Edit', + accessorKey: 'edit', + cell: ({ row }) => { + return ( +
+ +
+ ); + }, + }, + ]} + data={users} + perPage={perPage} + currentPage={page} + totalPages={totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx new file mode 100644 index 000000000..7f8b9af47 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -0,0 +1,35 @@ +import { findUsers } from '@documenso/lib/server-only/user/get-all-users'; + +/* +1. retrieve all users from the db +2. display them in a table +*/ +import { UsersDataTable } from './data-table-users'; + +type AdminManageUsersProps = { + searchParams?: { + page?: number; + perPage?: number; + }; +}; + +export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) { + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 10; + + const results = await findUsers({ page, perPage }); + + return ( +
+

Manage users

+
+ +
+
+ ); +} diff --git a/packages/lib/server-only/user/get-all-users.ts b/packages/lib/server-only/user/get-all-users.ts new file mode 100644 index 000000000..157a75d4a --- /dev/null +++ b/packages/lib/server-only/user/get-all-users.ts @@ -0,0 +1,37 @@ +import { prisma } from '@documenso/prisma'; + +type getAllUsersProps = { + page: number; + perPage: number; +}; + +export const findUsers = async ({ page = 1, perPage = 10 }: getAllUsersProps) => { + const [users, count] = await Promise.all([ + await prisma.user.findMany({ + select: { + id: true, + name: true, + email: true, + roles: true, + Subscription: { + select: { + id: true, + status: true, + planId: true, + priceId: true, + createdAt: true, + periodEnd: true, + }, + }, + }, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + }), + await prisma.user.count(), + ]); + + return { + users, + totalPages: Math.ceil(count / perPage), + }; +}; From 07bf780c3e52d252bd9a70399f620b4f150df195 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 21 Sep 2023 15:10:20 +0100 Subject: [PATCH 06/48] feat: build individual user page --- .../app/(dashboard)/admin/users/[id]/page.tsx | 85 ++++++++++++++++++- packages/trpc/server/profile-router/router.ts | 27 ++++++ packages/trpc/server/profile-router/schema.ts | 5 ++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 3b8cfa287..ddeb52058 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -1,8 +1,87 @@ -export default function UserPage() { +'use client'; + +import { Loader } from 'lucide-react'; +import { Controller, useForm } from 'react-hook-form'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { Input } from '@documenso/ui/primitives/input'; +import { Label } from '@documenso/ui/primitives/label'; +import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { FormErrorMessage } from '../../../../../components/form/form-error-message'; + +export default function UserPage({ params }: { params: { id: number } }) { + const toast = useToast(); + + const result = trpc.profile.getUser.useQuery( + { + id: Number(params.id), + }, + { + enabled: !!params.id, + }, + ); + + const { + register, + control, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm(); + + console.log(result.data); + + const onSubmit = async (data) => { + console.log(data); + }; + return (
-

Hey

-

Ho

+

Manage {result.data?.name}'s profile

+
+
+ + + +
+
+ + + +
+
+ + +
+ ( + onChange(v ?? '')} + /> + )} + /> + +
+
+
+ +
+
); } diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index bbeff675b..c5756c480 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,6 +1,8 @@ import { TRPCError } from '@trpc/server'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; 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 { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; @@ -9,11 +11,36 @@ import { authenticatedProcedure, procedure, router } from '../trpc'; import { ZForgotPasswordFormSchema, ZResetPasswordFormSchema, + ZRetrieveUserByIdQuerySchema, ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema, } from './schema'; export const profileRouter = router({ + getUser: authenticatedProcedure + .input(ZRetrieveUserByIdQuerySchema) + .query(async ({ input, ctx }) => { + const isUserAdmin = isAdmin(ctx.user); + + if (!isUserAdmin) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Not authorized to perform this action.', + }); + } + + try { + const { id } = input; + + return await getUserById({ id }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the specified account. Please try again.', + }); + } + }), + updateProfile: authenticatedProcedure .input(ZUpdateProfileMutationSchema) .mutation(async ({ input, ctx }) => { diff --git a/packages/trpc/server/profile-router/schema.ts b/packages/trpc/server/profile-router/schema.ts index 641227684..a910ec3cc 100644 --- a/packages/trpc/server/profile-router/schema.ts +++ b/packages/trpc/server/profile-router/schema.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; +export const ZRetrieveUserByIdQuerySchema = z.object({ + id: z.number().min(1), +}); + export const ZUpdateProfileMutationSchema = z.object({ name: z.string().min(1), signature: z.string(), @@ -18,6 +22,7 @@ export const ZResetPasswordFormSchema = z.object({ token: z.string().min(1), }); +export type TRetrieveUserByIdQuerySchema = z.infer; export type TUpdateProfileMutationSchema = z.infer; export type TUpdatePasswordMutationSchema = z.infer; export type TForgotPasswordFormSchema = z.infer; From f1bc772985b6feae7cf002b403e1db34aab8de1c Mon Sep 17 00:00:00 2001 From: pit Date: Fri, 29 Sep 2023 17:12:02 +0100 Subject: [PATCH 07/48] chore: improve the ui --- .../admin/users/data-table-users.tsx | 39 +++++++-- packages/lib/server-only/admin/update-user.ts | 30 +++++++ .../lib/server-only/user/get-all-users.ts | 5 ++ packages/ui/primitives/combobox.tsx | 84 +++++++++++++++++++ 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 packages/lib/server-only/admin/update-user.ts create mode 100644 packages/ui/primitives/combobox.tsx diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index 890d5cd48..0329b6a17 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -18,6 +18,7 @@ interface User { email: string; roles: Role[]; Subscription: Subscription[]; + Document: Document[]; } interface Subscription { @@ -36,10 +37,13 @@ type UsersDataTableProps = { totalPages: number; }; +type Document = { + id: number; +}; + export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTableProps) => { const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); - console.log(users); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -75,9 +79,9 @@ export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTa cell: ({ row }) => { return ( <> - {row.original.roles.map((role: string, idx: number) => { + {row.original.roles.map((role: string, i: number) => { return ( - + {role} {} ); @@ -87,15 +91,32 @@ export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTa }, }, { - header: 'Subscription status', + header: 'Subscription', accessorKey: 'subscription', + cell: ({ row }) => { + if (row.original.Subscription && row.original.Subscription.length > 0) { + return ( + <> + {row.original.Subscription.map((subscription: Subscription, i: number) => { + return {subscription.status}; + })} + + ); + } else { + return NONE; + } + }, + }, + { + header: 'Documents', + accessorKey: 'documents', cell: ({ row }) => { return ( - <> - {row.original.Subscription.map((subscription: Subscription, idx: number) => { - return {subscription.status}; - })} - +
+ + {row.original.Document.length} + +
); }, }, diff --git a/packages/lib/server-only/admin/update-user.ts b/packages/lib/server-only/admin/update-user.ts new file mode 100644 index 000000000..b10e6477d --- /dev/null +++ b/packages/lib/server-only/admin/update-user.ts @@ -0,0 +1,30 @@ +import { prisma } from '@documenso/prisma'; +import { Role } from '@documenso/prisma/client'; + +export type UpdateUserOptions = { + userId: number; + name: string; + email: string; + roles: Role[]; +}; + +export const updateUser = async ({ userId, name, email, roles }: UpdateUserOptions) => { + console.log('wtf'); + await prisma.user.findFirstOrThrow({ + where: { + id: userId, + }, + }); + + const updatedUser = await prisma.user.update({ + where: { + id: userId, + }, + data: { + name, + email, + roles, + }, + }); + return updatedUser; +}; diff --git a/packages/lib/server-only/user/get-all-users.ts b/packages/lib/server-only/user/get-all-users.ts index 157a75d4a..35e165260 100644 --- a/packages/lib/server-only/user/get-all-users.ts +++ b/packages/lib/server-only/user/get-all-users.ts @@ -23,6 +23,11 @@ export const findUsers = async ({ page = 1, perPage = 10 }: getAllUsersProps) => periodEnd: true, }, }, + Document: { + select: { + id: true, + }, + }, }, skip: Math.max(page - 1, 0) * perPage, take: perPage, diff --git a/packages/ui/primitives/combobox.tsx b/packages/ui/primitives/combobox.tsx new file mode 100644 index 000000000..6e566e188 --- /dev/null +++ b/packages/ui/primitives/combobox.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; + +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Role } from '@documenso/prisma/client'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@documenso/ui/primitives/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; + +type ComboboxProps = { + listValues: string[]; + onChange: (values: string[]) => void; +}; + +const Combobox = ({ listValues, onChange }: ComboboxProps) => { + const [open, setOpen] = React.useState(false); + const [selectedValues, setSelectedValues] = React.useState([]); + const dbRoles = Object.values(Role); + + React.useEffect(() => { + setSelectedValues(listValues); + }, [listValues]); + + const allRoles = [...new Set([...dbRoles, ...selectedValues])]; + + const handleSelect = (currentValue: string) => { + let newSelectedValues; + if (selectedValues.includes(currentValue)) { + newSelectedValues = selectedValues.filter((value) => value !== currentValue); + } else { + newSelectedValues = [...selectedValues, currentValue]; + } + + setSelectedValues(newSelectedValues); + onChange(newSelectedValues); + setOpen(false); + }; + + return ( + <> + + + + + + + + No value found. + + {allRoles.map((value: string, i: number) => ( + handleSelect(value)}> + + {value} + + ))} + + + + + + ); +}; + +export { Combobox }; From c2cda0f06e907232d98e37f570dafd3194b09180 Mon Sep 17 00:00:00 2001 From: pit Date: Fri, 29 Sep 2023 17:26:37 +0100 Subject: [PATCH 08/48] feat: update user functionality --- .../app/(dashboard)/admin/users/[id]/page.tsx | 75 +++++++++++++++++-- packages/lib/server-only/admin/update-user.ts | 8 +- packages/trpc/server/admin-router/router.ts | 33 ++++++++ packages/trpc/server/admin-router/schema.ts | 13 ++++ packages/trpc/server/router.ts | 2 + 5 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 packages/trpc/server/admin-router/router.ts create mode 100644 packages/trpc/server/admin-router/schema.ts diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index ddeb52058..d6b0d9d25 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -1,10 +1,13 @@ 'use client'; +import { useRouter } from 'next/navigation'; + import { Loader } from 'lucide-react'; import { Controller, useForm } from 'react-hook-form'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; +import { Combobox } from '@documenso/ui/primitives/combobox'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; @@ -13,7 +16,8 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { FormErrorMessage } from '../../../../../components/form/form-error-message'; export default function UserPage({ params }: { params: { id: number } }) { - const toast = useToast(); + const { toast } = useToast(); + const router = useRouter(); const result = trpc.profile.getUser.useQuery( { @@ -24,6 +28,15 @@ export default function UserPage({ params }: { params: { id: number } }) { }, ); + const roles = result.data?.roles; + let rolesArr: string[] = []; + + if (roles) { + rolesArr = Object.values(roles); + } + + const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation(); + const { register, control, @@ -31,10 +44,44 @@ export default function UserPage({ params }: { params: { id: number } }) { formState: { errors, isSubmitting }, } = useForm(); - console.log(result.data); - const onSubmit = async (data) => { console.log(data); + + // const submittedRoles = data.roles + // .split(',') + // .map((role: string) => role.trim()) + // .map((role: string) => role.toUpperCase()); + + // const dbRoles = JSON.stringify(Role); + + // const roleExists = submittedRoles.some((role: string) => dbRoles.includes(role)); + // console.log('roleExists', roleExists); + + // console.log('db roles', dbRoles); + + try { + await updateUserMutation({ + id: Number(result.data?.id), + name: data.name, + email: data.email, + roles: data.roles, + }); + + router.refresh(); + + toast({ + title: 'Profile updated', + description: 'Your profile has been updated.', + duration: 5000, + }); + } catch (e) { + console.log(e); + toast({ + title: 'Error', + description: 'An error occurred while updating your profile.', + variant: 'destructive', + }); + } }; return ( @@ -45,17 +92,30 @@ export default function UserPage({ params }: { params: { id: number } }) { - +
- +
-
+
+ + ( + onChange(values)} /> + )} + /> + +
+ {/*
@@ -74,7 +134,8 @@ export default function UserPage({ params }: { params: { id: number } }) { />
-
+ */} +
+ )) + .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( + + )) + .otherwise(() => ( + + )); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx new file mode 100644 index 000000000..72fdb4845 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx @@ -0,0 +1,124 @@ +'use client'; + +import Link from 'next/link'; + +import { + Copy, + Download, + Edit, + History, + MoreHorizontal, + Pencil, + Share, + Trash2, + XCircle, +} from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; +import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; +import { trpc } from '@documenso/trpc/client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; + +export type DataTableActionDropdownProps = { + row: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = row.User.id === session.user.id; + // const isRecipient = !!recipient; + // const isDraft = row.status === DocumentStatus.DRAFT; + // const isPending = row.status === DocumentStatus.PENDING; + const isComplete = row.status === DocumentStatus.COMPLETED; + // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; + + const onDownloadClick = async () => { + let document: DocumentWithData | null = null; + + if (!recipient) { + document = await trpc.document.getDocumentById.query({ + id: row.id, + }); + } else { + document = await trpc.document.getDocumentByToken.query({ + token: recipient.token, + }); + } + + const documentData = document?.documentData; + + if (!documentData) { + return; + } + + const documentBytes = await getFile(documentData); + + const blob = new Blob([documentBytes], { + type: 'application/pdf', + }); + + const link = window.document.createElement('a'); + + link.href = window.URL.createObjectURL(blob); + link.download = row.title || 'document.pdf'; + + link.click(); + + window.URL.revokeObjectURL(link.href); + }; + + return ( + + + + + + + Action + + + + Download + + + + + Duplicate + + + + + Void + + + + + Delete + + + + + Resend + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx new file mode 100644 index 000000000..c04f9f13d --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx @@ -0,0 +1,56 @@ +'use client'; + +import Link from 'next/link'; + +import { useSession } from 'next-auth/react'; +import { match } from 'ts-pattern'; + +import { Document, Recipient, User } from '@documenso/prisma/client'; + +export type DataTableTitleProps = { + row: Document & { + User: Pick; + Recipient: Recipient[]; + }; +}; + +export const DataTableTitle = ({ row }: DataTableTitleProps) => { + const { data: session } = useSession(); + + if (!session) { + return null; + } + + const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); + + const isOwner = row.User.id === session.user.id; + const isRecipient = !!recipient; + + return match({ + isOwner, + isRecipient, + }) + .with({ isOwner: true }, () => ( + + {row.title} + + )) + .with({ isRecipient: true }, () => ( + + {row.title} + + )) + .otherwise(() => ( + + {row.title} + + )); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx new file mode 100644 index 000000000..b1ab92e42 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { useTransition } from 'react'; + +import Link from 'next/link'; + +import { Loader } from 'lucide-react'; +import { useSession } from 'next-auth/react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { FindResultSet } from '@documenso/lib/types/find-result-set'; +import { Document, Recipient, User } from '@documenso/prisma/client'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; + +import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; +import { DocumentStatus } from '~/components/formatter/document-status'; +import { LocaleDate } from '~/components/formatter/locale-date'; + +import { DataTableActionButton } from './data-table-action-button'; +import { DataTableActionDropdown } from './data-table-action-dropdown'; +import { DataTableTitle } from './data-table-title'; + +export type DocumentsDataTableProps = { + results: FindResultSet< + Document & { + Recipient: Recipient[]; + User: Pick; + } + >; +}; + +export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { + const { data: session } = useSession(); + const [isPending, startTransition] = useTransition(); + + const updateSearchParams = useUpdateSearchParams(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + if (!session) { + return null; + } + + return ( +
+ , + }, + { + header: 'Title', + cell: ({ row }) => , + }, + { + header: 'Owner', + accessorKey: 'owner', + cell: ({ row }) => { + return ( + + + + ); + }, + }, + { + header: 'Status', + accessorKey: 'status', + cell: ({ row }) => , + }, + { + header: 'Actions', + cell: ({ row }) => ( +
+ + +
+ ), + }, + ]} + data={results.data} + perPage={results.perPage} + currentPage={results.currentPage} + totalPages={results.totalPages} + onPaginationChange={onPaginationChange} + > + {(table) => } +
+ + {isPending && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/page.tsx new file mode 100644 index 000000000..d62d82ada --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx @@ -0,0 +1,36 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; + +import { DocumentsDataTable } from './data-table'; + +export type DocumentsPageProps = { + searchParams?: { + page?: string; + perPage?: string; + }; +}; + +export default async function Documents({ searchParams = {} }: DocumentsPageProps) { + const user = await getRequiredServerComponentSession(); + const page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 20; + + const results = await findDocuments({ + userId: user.id, + orderBy: { + column: 'createdAt', + direction: 'desc', + }, + page, + perPage, + }); + + return ( +
+

Manage documents

+
+ +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/nav.tsx b/apps/web/src/app/(dashboard)/admin/nav.tsx index 0b59335bf..8050f867a 100644 --- a/apps/web/src/app/(dashboard)/admin/nav.tsx +++ b/apps/web/src/app/(dashboard)/admin/nav.tsx @@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { BarChart3, User2 } from 'lucide-react'; +import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -44,6 +44,34 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => { Users + + + +
); }; diff --git a/packages/lib/server-only/admin/get-all-documents.ts b/packages/lib/server-only/admin/get-all-documents.ts new file mode 100644 index 000000000..a1abdb186 --- /dev/null +++ b/packages/lib/server-only/admin/get-all-documents.ts @@ -0,0 +1,67 @@ +import { prisma } from '@documenso/prisma'; +import { Document } from '@documenso/prisma/client'; + +export interface FindDocumentsOptions { + term?: string; + page?: number; + perPage?: number; + orderBy?: { + column: keyof Omit; + direction: 'asc' | 'desc'; + }; +} + +export const findDocuments = async ({ + term, + page = 1, + perPage = 10, + orderBy, +}: FindDocumentsOptions) => { + const orderByColumn = orderBy?.column ?? 'createdAt'; + const orderByDirection = orderBy?.direction ?? 'desc'; + + const termFilters = !term + ? undefined + : ({ + title: { + contains: term, + mode: 'insensitive', + }, + } as const); + + const [data, count] = await Promise.all([ + prisma.document.findMany({ + where: { + ...termFilters, + }, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + [orderByColumn]: orderByDirection, + }, + include: { + User: { + select: { + id: true, + name: true, + email: true, + }, + }, + Recipient: true, + }, + }), + prisma.document.count({ + where: { + ...termFilters, + }, + }), + ]); + + return { + data, + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + }; +}; From 70ecc9a4a8da528cca9c6f495389f90bc93f5281 Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 3 Oct 2023 09:53:47 +0100 Subject: [PATCH 11/48] feat: add playwright --- .github/workflows/e2e-tests.yml | 51 +++ package-lock.json | 339 +++++++++++++++++- package.json | 3 +- packages/web-tests/.gitignore | 4 + packages/web-tests/e2e/test-auth-flow.spec.ts | 55 +++ packages/web-tests/package.json | 21 ++ packages/web-tests/playwright.config.ts | 77 ++++ turbo.json | 19 +- 8 files changed, 556 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/e2e-tests.yml create mode 100644 packages/web-tests/.gitignore create mode 100644 packages/web-tests/e2e/test-auth-flow.spec.ts create mode 100644 packages/web-tests/package.json create mode 100644 packages/web-tests/playwright.config.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 000000000..ecd1d1d1a --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,51 @@ +name: Playwright Tests +on: + push: + branches: [feat/refresh, feat/add-e2e-testing-playwright] + pull_request: + branches: [feat/refresh, feat/add-e2e-testing-playwright] +jobs: + e2e_tests: + timeout-minutes: 60 + runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + env: + NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso + NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - name: Generate package-lock.json + run: npm cache clean --force && npm install + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Install Prisma Client + run: npm install @prisma/client + - name: Generate Prisma Client + run: npm run prisma:generate -w @documenso/prisma + - name: Create the database + run: npm run prisma:migrate-dev -w @documenso/prisma + - name: Run Playwright tests + run: npm run ci + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/package-lock.json b/package-lock.json index c14609103..404043d20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2461,6 +2461,19 @@ "node": ">=6" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@hookform/resolvers": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz", @@ -3797,6 +3810,21 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.38.1.tgz", + "integrity": "sha512-NqRp8XMwj3AK+zKLbZShl0r/9wKgzqI/527bkptKXomtuo+dOjU9NdMASQ8DNC9z9zLOMbG53T4eihYr3XR+BQ==", + "dev": true, + "dependencies": { + "playwright": "1.38.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@prisma/client": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.3.1.tgz", @@ -5446,6 +5474,24 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "node_modules/@sindresorhus/slugify": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", @@ -7630,6 +7676,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-more-types": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -9135,6 +9189,11 @@ "node": ">=12" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + }, "node_modules/duplexer2": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", @@ -10339,6 +10398,20 @@ "node": ">=0.10.0" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -10738,6 +10811,11 @@ } } }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==" + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -12176,6 +12254,18 @@ "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==" }, + "node_modules/joi": { + "version": "17.10.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.10.2.tgz", + "integrity": "sha512-hcVhjBxRNW/is3nNLdGLIjkgXetkeGc2wyhydhz8KumG23Aerk4HPjU5zaPAMRqXQFc0xNqXTC7+zQjxr0GlKA==", + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jose": { "version": "4.14.4", "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", @@ -12410,6 +12500,14 @@ "language-subtag-registry": "~0.3.2" } }, + "node_modules/lazy-ass": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", + "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", + "engines": { + "node": "> 0.8" + } + }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", @@ -12957,6 +13055,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==" + }, "node_modules/markdown-extensions": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz", @@ -15010,6 +15113,14 @@ "node": ">=8" } }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dependencies": { + "through": "~2.3" + } + }, "node_modules/pdf-lib": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", @@ -15108,6 +15219,36 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.1.tgz", + "integrity": "sha512-oRMSJmZrOu1FP5iu3UrCx8JEFRIMxLDM0c/3o4bpzU5Tz97BypefWf7TuTNPWeCe279TPal5RtPPZ+9lW/Qkow==", + "dev": true, + "dependencies": { + "playwright-core": "1.38.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.38.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.1.tgz", + "integrity": "sha512-tQqNFUKa3OfMf4b2jQ7aGLB8o9bS3bOY0yMEtldtC2+spf8QXG9zvXLTXUeRsoNuxEYMgLYR+NXfAa1rjKRcrg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/postcss": { "version": "8.4.27", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", @@ -15658,6 +15799,20 @@ "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, + "node_modules/ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", + "dependencies": { + "event-stream": "=3.3.4" + }, + "bin": { + "ps-tree": "bin/ps-tree.js" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -17301,6 +17456,14 @@ "resolved": "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz", "integrity": "sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==" }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -17676,6 +17839,17 @@ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==" }, + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, "node_modules/split2": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", @@ -17707,6 +17881,121 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "node_modules/start-server-and-test": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.1.tgz", + "integrity": "sha512-8PFo4DLLLCDMuS51/BEEtE1m9CAXw1LNVtZSS1PzkYQh6Qf9JUwM4huYeSoUumaaoAyuwYBwCa9OsrcpMqcOdQ==", + "dependencies": { + "arg": "^5.0.2", + "bluebird": "3.7.2", + "check-more-types": "2.24.0", + "debug": "4.3.4", + "execa": "5.1.1", + "lazy-ass": "1.6.0", + "ps-tree": "1.2.0", + "wait-on": "7.0.1" + }, + "bin": { + "server-test": "src/bin/start.js", + "start-server-and-test": "src/bin/start.js", + "start-test": "src/bin/start.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/start-server-and-test/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/start-server-and-test/node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "node_modules/start-server-and-test/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/start-server-and-test/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/start-server-and-test/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/start-server-and-test/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/start-server-and-test/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/start-server-and-test/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/start-server-and-test/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "engines": { + "node": ">=6" + } + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -17715,6 +18004,14 @@ "node": ">= 0.6" } }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "dependencies": { + "duplexer": "~0.1.1" + } + }, "node_modules/stream-shift": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", @@ -18260,8 +18557,7 @@ "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" }, "node_modules/through2": { "version": "4.0.2", @@ -19522,6 +19818,24 @@ "d3-timer": "^3.0.1" } }, + "node_modules/wait-on": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz", + "integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==", + "dependencies": { + "axios": "^0.27.2", + "joi": "^17.7.0", + "lodash": "^4.17.21", + "minimist": "^1.2.7", + "rxjs": "^7.8.0" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -19559,6 +19873,10 @@ "node": ">= 8" } }, + "node_modules/web-tests": { + "resolved": "packages/web-tests", + "link": true + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -20009,6 +20327,23 @@ "react": "18.2.0", "typescript": "^5.1.6" } + }, + "packages/web-tests": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "start-server-and-test": "^2.0.1" + }, + "devDependencies": { + "@playwright/test": "^1.38.1", + "@types/node": "^20.8.2" + } + }, + "packages/web-tests/node_modules/@types/node": { + "version": "20.8.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.2.tgz", + "integrity": "sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==", + "dev": true } } } diff --git a/package.json b/package.json index bb574f3ca..ba6b2ebdf 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "d": "npm run dx && npm run dev", "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev -w @documenso/prisma", "dx:up": "docker compose -f docker/compose-services.yml up -d", - "dx:down": "docker compose -f docker/compose-services.yml down" + "dx:down": "docker compose -f docker/compose-services.yml down", + "ci": "turbo run build test:e2e lint" }, "engines": { "npm": ">=8.6.0", diff --git a/packages/web-tests/.gitignore b/packages/web-tests/.gitignore new file mode 100644 index 000000000..75e854d8d --- /dev/null +++ b/packages/web-tests/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/packages/web-tests/e2e/test-auth-flow.spec.ts b/packages/web-tests/e2e/test-auth-flow.spec.ts new file mode 100644 index 000000000..544bdf48f --- /dev/null +++ b/packages/web-tests/e2e/test-auth-flow.spec.ts @@ -0,0 +1,55 @@ +import { type Page, expect, test } from '@playwright/test'; + +import { deleteUserAndItsData } from '@documenso/lib/server-only/user/delete-user-and-data'; + +test.use({ storageState: { cookies: [], origins: [] } }); + +/* + Using them sequentially so the 2nd test + uses the details from the 1st (registration) test +*/ +test.describe.configure({ mode: 'serial' }); + +const username = 'Test user'; +const email = 'testuser@gmail.com'; +const password = '12345678910'; + +test('user can sign up with email and password', async ({ page }: { page: Page }) => { + await page.goto('/signup'); + await page.getByLabel('Name').fill(username); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password', { exact: true }).fill(password); + + const canvas = page.locator('canvas'); + const box = await canvas.boundingBox(); + + if (box) { + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4); + await page.mouse.up(); + } + + await page.getByRole('button', { name: 'Sign Up' }).click(); + await page.waitForURL('/documents'); + + await expect(page).toHaveURL('/documents'); +}); + +test('user can login with user and password', async ({ page }: { page: Page }) => { + await page.goto('/signin'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Sign In' }).click(); + + await page.waitForURL('/documents'); + await expect(page).toHaveURL('/documents'); +}); + +test.afterAll('Teardown', async () => { + try { + await deleteUserAndItsData(username); + } catch (e) { + throw new Error(`Error deleting user: ${e}`); + } +}); diff --git a/packages/web-tests/package.json b/packages/web-tests/package.json new file mode 100644 index 000000000..fdf2f3268 --- /dev/null +++ b/packages/web-tests/package.json @@ -0,0 +1,21 @@ +{ + "name": "web-tests", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test:dev": "playwright test", + "test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\"" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.18.1", + "@types/node": "^20.8.2", + "@documenso/web": "*" + }, + "dependencies": { + "start-server-and-test": "^2.0.1" + } +} diff --git a/packages/web-tests/playwright.config.ts b/packages/web-tests/playwright.config.ts new file mode 100644 index 000000000..463b6f97d --- /dev/null +++ b/packages/web-tests/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/turbo.json b/turbo.json index 0fc3d8f9c..368c3a880 100644 --- a/turbo.json +++ b/turbo.json @@ -2,13 +2,8 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - ".next/**", - "!.next/cache/**" - ] + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**"] }, "lint": {}, "clean": { @@ -17,11 +12,15 @@ "dev": { "cache": false, "persistent": true + }, + "dev:test": { + "cache": false + }, + "test:e2e": { + "dependsOn": ["^build"] } }, - "globalDependencies": [ - "**/.env.*local" - ], + "globalDependencies": ["**/.env.*local"], "globalEnv": [ "APP_VERSION", "NEXTAUTH_URL", From 2e800d0eed08780e4e1eb7c455662e5dc93d09d9 Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 3 Oct 2023 10:01:44 +0100 Subject: [PATCH 12/48] chore: removed lint step --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ba6b2ebdf..d072aa76d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev -w @documenso/prisma", "dx:up": "docker compose -f docker/compose-services.yml up -d", "dx:down": "docker compose -f docker/compose-services.yml down", - "ci": "turbo run build test:e2e lint" + "ci": "turbo run build test:e2e" }, "engines": { "npm": ">=8.6.0", From 8848df701c6a7a30750607eed749182fa6b609fd Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 3 Oct 2023 10:09:40 +0100 Subject: [PATCH 13/48] chore: added delete function --- .../server-only/user/delete-user-and-data.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/lib/server-only/user/delete-user-and-data.ts diff --git a/packages/lib/server-only/user/delete-user-and-data.ts b/packages/lib/server-only/user/delete-user-and-data.ts new file mode 100644 index 000000000..3cfac13f6 --- /dev/null +++ b/packages/lib/server-only/user/delete-user-and-data.ts @@ -0,0 +1,41 @@ +import { prisma } from '@documenso/prisma'; + +export const deleteUserAndItsData = async (name: string) => { + const user = await prisma.user.findFirst({ + where: { + name: { + contains: name, + }, + }, + }); + + if (!user) { + throw new Error(`User with name ${name} not found`); + } + + const document = await prisma.document.findMany({ + where: { + userId: user.id, + }, + select: { + documentData: { + select: { + data: true, + }, + }, + }, + }); + + return prisma.$transaction([ + prisma.user.delete({ + where: { + id: user.id, + }, + }), + prisma.documentData.deleteMany({ + where: { + data: document[0]?.documentData.data, + }, + }), + ]); +}; From 7bc1e9dcc845bbb02d42bac2eefdcde58115594f Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 3 Oct 2023 10:19:54 +0100 Subject: [PATCH 14/48] chore: add env step in gh action --- .github/workflows/e2e-tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index ecd1d1d1a..b959c1305 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -3,7 +3,7 @@ on: push: branches: [feat/refresh, feat/add-e2e-testing-playwright] pull_request: - branches: [feat/refresh, feat/add-e2e-testing-playwright] + branches: [feat/refresh] jobs: e2e_tests: timeout-minutes: 60 @@ -33,6 +33,8 @@ jobs: run: npm cache clean --force && npm install - name: Install dependencies run: npm ci + - name: Copy env + run: cp .env.example .env - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Install Prisma Client From 2efaabd2c32a4fbaf22f9028bbf39dd650c802d9 Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 3 Oct 2023 10:20:39 +0100 Subject: [PATCH 15/48] ci: trigger ci --- .github/workflows/e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b959c1305..7a2388eb1 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -3,7 +3,7 @@ on: push: branches: [feat/refresh, feat/add-e2e-testing-playwright] pull_request: - branches: [feat/refresh] + branches: [feat/refresh, feat/add-e2e-testing-playwright] jobs: e2e_tests: timeout-minutes: 60 From d10713b47718aa00351766fdd5a73ad65cbb4f25 Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 3 Oct 2023 10:21:48 +0100 Subject: [PATCH 16/48] ci: trigger ci --- .github/workflows/e2e-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7a2388eb1..77b5a8da3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -3,7 +3,7 @@ on: push: branches: [feat/refresh, feat/add-e2e-testing-playwright] pull_request: - branches: [feat/refresh, feat/add-e2e-testing-playwright] + branches: [feat/refresh] jobs: e2e_tests: timeout-minutes: 60 @@ -33,7 +33,7 @@ jobs: run: npm cache clean --force && npm install - name: Install dependencies run: npm ci - - name: Copy env + - name: Copy env run: cp .env.example .env - name: Install Playwright Browsers run: npx playwright install --with-deps From 39ff11a59d64f35f2163f2873813519e4ce541bd Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 09:12:56 +0300 Subject: [PATCH 17/48] chore: use env vars for tests --- .env.example | 5 +++++ package-lock.json | 3 ++- packages/web-tests/e2e/test-auth-flow.spec.ts | 6 +++--- turbo.json | 5 ++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 065976bc5..44baac9c8 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documen # Defines the URL to use for the database when running migrations and other commands that won't work with a connection pool. NEXT_PRIVATE_DIRECT_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" +# [[E2E Tests]] +E2E_TEST_AUTHENTICATE_USERNAME="Test User" +E2E_TEST_AUTHENTICATE_USER_EMAIL="testuser@mail.com" +E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password" + # [[STORAGE]] # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 NEXT_PUBLIC_UPLOAD_TRANSPORT="database" diff --git a/package-lock.json b/package-lock.json index 05c267d9f..09985288c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20335,7 +20335,8 @@ "start-server-and-test": "^2.0.1" }, "devDependencies": { - "@playwright/test": "^1.38.1", + "@documenso/web": "*", + "@playwright/test": "^1.18.1", "@types/node": "^20.8.2" } }, diff --git a/packages/web-tests/e2e/test-auth-flow.spec.ts b/packages/web-tests/e2e/test-auth-flow.spec.ts index 544bdf48f..c53a9577f 100644 --- a/packages/web-tests/e2e/test-auth-flow.spec.ts +++ b/packages/web-tests/e2e/test-auth-flow.spec.ts @@ -10,9 +10,9 @@ test.use({ storageState: { cookies: [], origins: [] } }); */ test.describe.configure({ mode: 'serial' }); -const username = 'Test user'; -const email = 'testuser@gmail.com'; -const password = '12345678910'; +const username = E2E_TEST_AUTHENTICATE_USERNAME; +const email = E2E_TEST_AUTHENTICATE_USER_EMAIL; +const password = E2E_TEST_AUTHENTICATE_USER_PASSWORD; test('user can sign up with email and password', async ({ page }: { page: Page }) => { await page.goto('/signup'); diff --git a/turbo.json b/turbo.json index 368c3a880..10ff74735 100644 --- a/turbo.json +++ b/turbo.json @@ -72,6 +72,9 @@ "POSTGRES_URL", "DATABASE_URL", "POSTGRES_PRISMA_URL", - "POSTGRES_URL_NON_POOLING" + "POSTGRES_URL_NON_POOLING", + "E2E_TEST_AUTHENTICATE_USERNAME", + "E2E_TEST_AUTHENTICATE_USER_EMAIL", + "E2E_TEST_AUTHENTICATE_USER_PASSWORD" ] } From 742ad86b1065b687065a09f69bc93f97859bc4d1 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 10:13:43 +0300 Subject: [PATCH 18/48] chore: add remote caching --- .github/workflows/e2e-tests.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 77b5a8da3..eb1e7117b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -21,9 +21,6 @@ jobs: --health-retries 5 ports: - 5432:5432 - env: - NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso - NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -51,3 +48,8 @@ jobs: name: playwright-report path: playwright-report/ retention-days: 30 + env: + NEXT_PRIVATE_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso + NEXT_PRIVATE_DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/documenso + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ vars.TURBO_TEAM }} From 85b32bb15b1a764035607f38d64b9984305c3eac Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 10:24:03 +0300 Subject: [PATCH 19/48] chore: install prisma before prisma client --- .github/workflows/e2e-tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index eb1e7117b..cfe28d660 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -34,6 +34,8 @@ jobs: run: cp .env.example .env - name: Install Playwright Browsers run: npx playwright install --with-deps + - name: Install Prisma + run: npm install prisma - name: Install Prisma Client run: npm install @prisma/client - name: Generate Prisma Client From a85523ecfc6df3295526398e2b0af1726f72fb79 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 10:53:19 +0300 Subject: [PATCH 20/48] chore: change from npm to npx --- .github/workflows/e2e-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index cfe28d660..599a6b5fd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -39,9 +39,9 @@ jobs: - name: Install Prisma Client run: npm install @prisma/client - name: Generate Prisma Client - run: npm run prisma:generate -w @documenso/prisma + run: npx prisma generate --schema packages/prisma/schema.prisma - name: Create the database - run: npm run prisma:migrate-dev -w @documenso/prisma + run: npx prisma migrate dev - name: Run Playwright tests run: npm run ci - uses: actions/upload-artifact@v3 From 64dcd451e956196c4a88510f8b676877c62ef3e4 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 10:56:09 +0300 Subject: [PATCH 21/48] chore: add schema location --- .github/workflows/e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 599a6b5fd..824423731 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -41,7 +41,7 @@ jobs: - name: Generate Prisma Client run: npx prisma generate --schema packages/prisma/schema.prisma - name: Create the database - run: npx prisma migrate dev + run: npx prisma migrate dev --schema packages/prisma/schema.prisma - name: Run Playwright tests run: npm run ci - uses: actions/upload-artifact@v3 From a3dce67117aa9710a41e96cbf9c5e6d148415cb8 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 11:38:51 +0300 Subject: [PATCH 22/48] chore: changes --- .github/workflows/e2e-tests.yml | 4 +- .../server-only/user/delete-user-and-data.ts | 41 ------------------- packages/lib/server-only/user/delete-user.ts | 21 ++++++++++ packages/prisma/schema.prisma | 17 ++++---- packages/web-tests/e2e/test-auth-flow.spec.ts | 10 ++--- 5 files changed, 37 insertions(+), 56 deletions(-) delete mode 100644 packages/lib/server-only/user/delete-user-and-data.ts create mode 100644 packages/lib/server-only/user/delete-user.ts diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 824423731..cfe28d660 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -39,9 +39,9 @@ jobs: - name: Install Prisma Client run: npm install @prisma/client - name: Generate Prisma Client - run: npx prisma generate --schema packages/prisma/schema.prisma + run: npm run prisma:generate -w @documenso/prisma - name: Create the database - run: npx prisma migrate dev --schema packages/prisma/schema.prisma + run: npm run prisma:migrate-dev -w @documenso/prisma - name: Run Playwright tests run: npm run ci - uses: actions/upload-artifact@v3 diff --git a/packages/lib/server-only/user/delete-user-and-data.ts b/packages/lib/server-only/user/delete-user-and-data.ts deleted file mode 100644 index 3cfac13f6..000000000 --- a/packages/lib/server-only/user/delete-user-and-data.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -export const deleteUserAndItsData = async (name: string) => { - const user = await prisma.user.findFirst({ - where: { - name: { - contains: name, - }, - }, - }); - - if (!user) { - throw new Error(`User with name ${name} not found`); - } - - const document = await prisma.document.findMany({ - where: { - userId: user.id, - }, - select: { - documentData: { - select: { - data: true, - }, - }, - }, - }); - - return prisma.$transaction([ - prisma.user.delete({ - where: { - id: user.id, - }, - }), - prisma.documentData.deleteMany({ - where: { - data: document[0]?.documentData.data, - }, - }), - ]); -}; diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts new file mode 100644 index 000000000..901a8a3d7 --- /dev/null +++ b/packages/lib/server-only/user/delete-user.ts @@ -0,0 +1,21 @@ +import { prisma } from '@documenso/prisma'; + +export const deleteUser = async (name: string) => { + const user = await prisma.user.findFirst({ + where: { + name: { + contains: name, + }, + }, + }); + + if (!user) { + throw new Error(`User with name ${name} not found`); + } + + return await prisma.user.delete({ + where: { + id: user.id, + }, + }); +}; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index c4f034ba2..fa604955f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { provider = "prisma-client-js" + output = "../../node_modules/.prisma/client" } datasource db { @@ -110,10 +111,10 @@ model Document { Field Field[] ShareLink DocumentShareLink[] documentDataId String - documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade) + documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade) documentMeta DocumentMeta? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt @@unique([documentDataId]) } @@ -133,11 +134,11 @@ model DocumentData { } model DocumentMeta { - id String @id @default(cuid()) - subject String? - message String? - documentId Int @unique - document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + subject String? + message String? + documentId Int @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) } enum ReadStatus { diff --git a/packages/web-tests/e2e/test-auth-flow.spec.ts b/packages/web-tests/e2e/test-auth-flow.spec.ts index c53a9577f..3d1d4cd39 100644 --- a/packages/web-tests/e2e/test-auth-flow.spec.ts +++ b/packages/web-tests/e2e/test-auth-flow.spec.ts @@ -1,6 +1,6 @@ import { type Page, expect, test } from '@playwright/test'; -import { deleteUserAndItsData } from '@documenso/lib/server-only/user/delete-user-and-data'; +import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; test.use({ storageState: { cookies: [], origins: [] } }); @@ -10,9 +10,9 @@ test.use({ storageState: { cookies: [], origins: [] } }); */ test.describe.configure({ mode: 'serial' }); -const username = E2E_TEST_AUTHENTICATE_USERNAME; -const email = E2E_TEST_AUTHENTICATE_USER_EMAIL; -const password = E2E_TEST_AUTHENTICATE_USER_PASSWORD; +const username = 'testuser'; +const email = 'test-user@documenso.com'; +const password = 'password'; test('user can sign up with email and password', async ({ page }: { page: Page }) => { await page.goto('/signup'); @@ -48,7 +48,7 @@ test('user can login with user and password', async ({ page }: { page: Page }) = test.afterAll('Teardown', async () => { try { - await deleteUserAndItsData(username); + await deleteUser(username); } catch (e) { throw new Error(`Error deleting user: ${e}`); } From 4e1994a0c85a05f431834d2e95ad4badbfac9733 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 11:46:45 +0300 Subject: [PATCH 23/48] chore: update import --- packages/prisma/schema.prisma | 1 - packages/ui/components/field/field-tooltip.tsx | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index fa604955f..266f8470a 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1,6 +1,5 @@ generator client { provider = "prisma-client-js" - output = "../../node_modules/.prisma/client" } datasource db { diff --git a/packages/ui/components/field/field-tooltip.tsx b/packages/ui/components/field/field-tooltip.tsx index 446b14d2d..6967ad24b 100644 --- a/packages/ui/components/field/field-tooltip.tsx +++ b/packages/ui/components/field/field-tooltip.tsx @@ -1,3 +1,4 @@ +import { Field } from '@prisma/client'; import { TooltipArrow } from '@radix-ui/react-tooltip'; import { VariantProps, cva } from 'class-variance-authority'; import { createPortal } from 'react-dom'; @@ -11,8 +12,6 @@ import { TooltipTrigger, } from '@documenso/ui/primitives/tooltip'; -import { Field } from '.prisma/client'; - const tooltipVariants = cva('font-semibold', { variants: { color: { From 2422c3e7bed83a38e0413ac294bfed472b6dddcb Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 11:56:32 +0300 Subject: [PATCH 24/48] chore: update e2e tests --- .github/workflows/e2e-tests.yml | 6 ------ packages/ui/components/field/field-tooltip.tsx | 3 ++- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index cfe28d660..af15997f1 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -26,18 +26,12 @@ jobs: - uses: actions/setup-node@v3 with: node-version: 18 - - name: Generate package-lock.json - run: npm cache clean --force && npm install - name: Install dependencies run: npm ci - name: Copy env run: cp .env.example .env - name: Install Playwright Browsers run: npx playwright install --with-deps - - name: Install Prisma - run: npm install prisma - - name: Install Prisma Client - run: npm install @prisma/client - name: Generate Prisma Client run: npm run prisma:generate -w @documenso/prisma - name: Create the database diff --git a/packages/ui/components/field/field-tooltip.tsx b/packages/ui/components/field/field-tooltip.tsx index 6967ad24b..446b14d2d 100644 --- a/packages/ui/components/field/field-tooltip.tsx +++ b/packages/ui/components/field/field-tooltip.tsx @@ -1,4 +1,3 @@ -import { Field } from '@prisma/client'; import { TooltipArrow } from '@radix-ui/react-tooltip'; import { VariantProps, cva } from 'class-variance-authority'; import { createPortal } from 'react-dom'; @@ -12,6 +11,8 @@ import { TooltipTrigger, } from '@documenso/ui/primitives/tooltip'; +import { Field } from '.prisma/client'; + const tooltipVariants = cva('font-semibold', { variants: { color: { From aecc703317c8b5446d03001ee46f861c4bc5928a Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 12:11:17 +0300 Subject: [PATCH 25/48] chore: remove this branch --- .github/workflows/e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index af15997f1..f39e842d3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,7 +1,7 @@ name: Playwright Tests on: push: - branches: [feat/refresh, feat/add-e2e-testing-playwright] + branches: [feat/refresh] pull_request: branches: [feat/refresh] jobs: From 2b44e54d99e5f17b69d8cbf1ed507264da03e814 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 5 Oct 2023 18:35:12 +0300 Subject: [PATCH 26/48] feat: subscriptions and documents page --- .../(dashboard)/admin/subscriptions/page.tsx | 65 +++++++++++++++++++ .../admin/users/[id]/documents/page.tsx | 3 + .../app/(dashboard)/admin/users/[id]/page.tsx | 1 - .../admin/get-all-subscriptions.ts | 13 ++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx create mode 100644 apps/web/src/app/(dashboard)/admin/users/[id]/documents/page.tsx create mode 100644 packages/lib/server-only/admin/get-all-subscriptions.ts diff --git a/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx b/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx new file mode 100644 index 000000000..68ccf1ee4 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/subscriptions/page.tsx @@ -0,0 +1,65 @@ +import Link from 'next/link'; + +import { findSubscriptions } from '@documenso/lib/server-only/admin/get-all-subscriptions'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@documenso/ui/primitives/table'; + +export default async function Subscriptions() { + const subscriptions = await findSubscriptions(); + + return ( +
+

Manage subscriptions

+
+ + + + ID + Status + Created At + Ends On + User ID + + + + {subscriptions.map((subscription, index) => ( + + {subscription.id} + {subscription.status} + + {subscription.createdAt + ? new Date(subscription.createdAt).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'N/A'} + + + {subscription.periodEnd + ? new Date(subscription.periodEnd).toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : 'N/A'} + + + {subscription.userId} + + + ))} + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/documents/page.tsx new file mode 100644 index 000000000..e17dc611b --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/documents/page.tsx @@ -0,0 +1,3 @@ +export default function UserDocuments() { + return

User docs

; +} diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 5bbc7b340..802f2ec0c 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -74,7 +74,6 @@ export default function UserPage({ params }: { params: { id: number } }) { duration: 5000, }); } catch (e) { - console.log(e); toast({ title: 'Error', description: 'An error occurred while updating your profile.', diff --git a/packages/lib/server-only/admin/get-all-subscriptions.ts b/packages/lib/server-only/admin/get-all-subscriptions.ts new file mode 100644 index 000000000..5080c4c22 --- /dev/null +++ b/packages/lib/server-only/admin/get-all-subscriptions.ts @@ -0,0 +1,13 @@ +import { prisma } from '@documenso/prisma'; + +export const findSubscriptions = async () => { + return prisma.subscription.findMany({ + select: { + id: true, + status: true, + createdAt: true, + periodEnd: true, + userId: true, + }, + }); +}; From 5f14f87406d9003ccd32864d6ba228e08d8b59ad Mon Sep 17 00:00:00 2001 From: pit Date: Fri, 6 Oct 2023 15:48:05 +0300 Subject: [PATCH 27/48] feat: filter users by name or email --- .../admin/documents/data-table.tsx | 16 ++-- .../app/(dashboard)/admin/documents/page.tsx | 2 - .../admin/users/data-table-users.tsx | 26 +++---- .../src/app/(dashboard)/admin/users/page.tsx | 25 +++--- .../src/app/(dashboard)/admin/users/users.tsx | 78 +++++++++++++++++++ .../(dashboard)/admin/generic-data-table.tsx | 57 ++++++++++++++ .../lib/server-only/user/get-all-users.ts | 33 +++++++- 7 files changed, 192 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/admin/users/users.tsx create mode 100644 apps/web/src/components/(dashboard)/admin/generic-data-table.tsx diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx index b1ab92e42..1d121742a 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -10,10 +10,10 @@ import { useSession } from 'next-auth/react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { FindResultSet } from '@documenso/lib/types/find-result-set'; import { Document, Recipient, User } from '@documenso/prisma/client'; +import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; -import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; @@ -68,15 +68,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { cell: ({ row }) => { return ( - + + + {row.original.User.name} + + ); }, diff --git a/apps/web/src/app/(dashboard)/admin/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/page.tsx index d62d82ada..6b5a0761c 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx @@ -11,12 +11,10 @@ export type DocumentsPageProps = { }; export default async function Documents({ searchParams = {} }: DocumentsPageProps) { - const user = await getRequiredServerComponentSession(); const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 20; const results = await findDocuments({ - userId: user.id, orderBy: { column: 'createdAt', direction: 'desc', diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index 0329b6a17..a598fc605 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -7,7 +7,7 @@ import Link from 'next/link'; import { Edit, Loader } from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { Role } from '@documenso/prisma/client'; +import { Document, Role, Subscription } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; @@ -17,18 +17,16 @@ interface User { name: string | null; email: string; roles: Role[]; - Subscription: Subscription[]; - Document: Document[]; + Subscription: SubscriptionLite[]; + Document: DocumentLite[]; } -interface Subscription { - id: number; - status: string; - planId: string | null; - priceId: string | null; - createdAt: Date | null; - periodEnd: Date | null; -} +type SubscriptionLite = Pick< + Subscription, + 'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd' +>; + +type DocumentLite = Pick; type UsersDataTableProps = { users: User[]; @@ -37,10 +35,6 @@ type UsersDataTableProps = { totalPages: number; }; -type Document = { - id: number; -}; - export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTableProps) => { const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); @@ -97,7 +91,7 @@ export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTa if (row.original.Subscription && row.original.Subscription.length > 0) { return ( <> - {row.original.Subscription.map((subscription: Subscription, i: number) => { + {row.original.Subscription.map((subscription: SubscriptionLite, i: number) => { return {subscription.status}; })} diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index 7f8b9af47..67dc5563b 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -1,10 +1,6 @@ import { findUsers } from '@documenso/lib/server-only/user/get-all-users'; -/* -1. retrieve all users from the db -2. display them in a table -*/ -import { UsersDataTable } from './data-table-users'; +import { Users } from './users'; type AdminManageUsersProps = { searchParams?: { @@ -13,23 +9,22 @@ type AdminManageUsersProps = { }; }; -export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) { +export default function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) { const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 10; - const results = await findUsers({ page, perPage }); + async function search(search: string) { + 'use server'; + + const results = await findUsers({ username: search, email: search, page, perPage }); + + return results; + } return (

Manage users

-
- -
+
); } diff --git a/apps/web/src/app/(dashboard)/admin/users/users.tsx b/apps/web/src/app/(dashboard)/admin/users/users.tsx new file mode 100644 index 000000000..e44772dbb --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/users.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { Document, Role, Subscription } from '@documenso/prisma/client'; +import { Button } from '@documenso/ui/primitives/button'; +import { Input } from '@documenso/ui/primitives/input'; + +import { UsersDataTable } from './data-table-users'; + +export type SubscriptionLite = Pick< + Subscription, + 'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd' +>; +export type DocumentLite = Pick; + +export type User = { + id: number; + name: string | null; + email: string; + roles: Role[]; + Subscription: SubscriptionLite[]; + Document: DocumentLite[]; +}; + +export type UsersProps = { + search: (search: string) => Promise<{ users: User[]; totalPages: number }>; + perPage: number; + page: number; +}; + +export const Users = ({ search, perPage, page }: UsersProps) => { + const [data, setData] = useState([]); + const [totalPages, setTotalPages] = useState(0); + + const [searchString, setSearchString] = useState(''); + + useEffect(() => { + const fetchData = async () => { + try { + const result = await search(searchString); + setData(result.users); + setTotalPages(result.totalPages); + } catch (err) { + throw new Error(err); + } + }; + + fetchData(); + }, [searchString, search]); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const result = await search(searchString); + setData(result.users); + }; + + const handleChange = (e: React.ChangeEvent) => { + setSearchString(e.target.value); + }; + + return ( + <> + + + + +
+ +
+ + ); +}; diff --git a/apps/web/src/components/(dashboard)/admin/generic-data-table.tsx b/apps/web/src/components/(dashboard)/admin/generic-data-table.tsx new file mode 100644 index 000000000..1dd3e01d3 --- /dev/null +++ b/apps/web/src/components/(dashboard)/admin/generic-data-table.tsx @@ -0,0 +1,57 @@ +import { useTransition } from 'react'; + +import { ColumnDef } from '@tanstack/react-table'; +import { Loader } from 'lucide-react'; + +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; + +type GenericDataTableProps = { + columns: ColumnDef[]; + data: TData[]; + perPage: number; + currentPage: number; + totalPages: number; +}; + +export function GenericDataTable({ + columns, + data, + perPage, + currentPage, + totalPages, +}: GenericDataTableProps) { + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page: page.toString(), + perPage: perPage.toString(), + }); + }); + }; + + return ( +
+ + {(table) => } + + + {isPending && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/lib/server-only/user/get-all-users.ts b/packages/lib/server-only/user/get-all-users.ts index 35e165260..babcc7ba1 100644 --- a/packages/lib/server-only/user/get-all-users.ts +++ b/packages/lib/server-only/user/get-all-users.ts @@ -1,11 +1,37 @@ +import { Prisma } from '@prisma/client'; + import { prisma } from '@documenso/prisma'; type getAllUsersProps = { + username: string; + email: string; page: number; perPage: number; }; -export const findUsers = async ({ page = 1, perPage = 10 }: getAllUsersProps) => { +export const findUsers = async ({ + username = '', + email = '', + page = 1, + perPage = 10, +}: getAllUsersProps) => { + const whereClause = Prisma.validator()({ + OR: [ + { + name: { + contains: username, + mode: 'insensitive', + }, + }, + { + email: { + contains: email, + mode: 'insensitive', + }, + }, + ], + }); + const [users, count] = await Promise.all([ await prisma.user.findMany({ select: { @@ -29,10 +55,13 @@ export const findUsers = async ({ page = 1, perPage = 10 }: getAllUsersProps) => }, }, }, + where: whereClause, skip: Math.max(page - 1, 0) * perPage, take: perPage, }), - await prisma.user.count(), + await prisma.user.count({ + where: whereClause, + }), ]); return { From d4ae733e9e7462c1418cf308a33194259bc53895 Mon Sep 17 00:00:00 2001 From: pit Date: Mon, 9 Oct 2023 11:59:08 +0300 Subject: [PATCH 28/48] chore: add transition and check for empty users array --- .../src/app/(dashboard)/admin/users/users.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/users/users.tsx b/apps/web/src/app/(dashboard)/admin/users/users.tsx index e44772dbb..c27f71472 100644 --- a/apps/web/src/app/(dashboard)/admin/users/users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/users.tsx @@ -1,11 +1,15 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useTransition } from 'react'; + +import { Loader } from 'lucide-react'; import { Document, Role, Subscription } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { Input } from '@documenso/ui/primitives/input'; +import { useDebouncedValue } from '~/hooks/use-debounced-value'; + import { UsersDataTable } from './data-table-users'; export type SubscriptionLite = Pick< @@ -24,7 +28,7 @@ export type User = { }; export type UsersProps = { - search: (search: string) => Promise<{ users: User[]; totalPages: number }>; + search: (_search: string) => Promise<{ users: User[]; totalPages: number }>; perPage: number; page: number; }; @@ -32,13 +36,14 @@ export type UsersProps = { export const Users = ({ search, perPage, page }: UsersProps) => { const [data, setData] = useState([]); const [totalPages, setTotalPages] = useState(0); - + const [isPending, startTransition] = useTransition(); const [searchString, setSearchString] = useState(''); + const debouncedSearchString = useDebouncedValue(searchString, 500); useEffect(() => { const fetchData = async () => { try { - const result = await search(searchString); + const result = await search(debouncedSearchString); setData(result.users); setTotalPages(result.totalPages); } catch (err) { @@ -47,12 +52,14 @@ export const Users = ({ search, perPage, page }: UsersProps) => { }; fetchData(); - }, [searchString, search]); + }, [debouncedSearchString, search]); - const onSubmit = async (e: React.FormEvent) => { + const onSubmit = (e: React.FormEvent) => { e.preventDefault(); - const result = await search(searchString); - setData(result.users); + startTransition(async () => { + const result = await search(debouncedSearchString); + setData(result.users); + }); }; const handleChange = (e: React.ChangeEvent) => { @@ -71,7 +78,13 @@ export const Users = ({ search, perPage, page }: UsersProps) => {
- + {data.length === 0 || isPending ? ( +
+ +
+ ) : ( + + )}
); From 4c518df60d94246d7375995a6548be4ad4a8caf9 Mon Sep 17 00:00:00 2001 From: pit Date: Mon, 9 Oct 2023 12:02:55 +0300 Subject: [PATCH 29/48] chore: remove generic data table --- .../(dashboard)/admin/generic-data-table.tsx | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 apps/web/src/components/(dashboard)/admin/generic-data-table.tsx diff --git a/apps/web/src/components/(dashboard)/admin/generic-data-table.tsx b/apps/web/src/components/(dashboard)/admin/generic-data-table.tsx deleted file mode 100644 index 1dd3e01d3..000000000 --- a/apps/web/src/components/(dashboard)/admin/generic-data-table.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useTransition } from 'react'; - -import { ColumnDef } from '@tanstack/react-table'; -import { Loader } from 'lucide-react'; - -import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; -import { DataTable } from '@documenso/ui/primitives/data-table'; -import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; - -type GenericDataTableProps = { - columns: ColumnDef[]; - data: TData[]; - perPage: number; - currentPage: number; - totalPages: number; -}; - -export function GenericDataTable({ - columns, - data, - perPage, - currentPage, - totalPages, -}: GenericDataTableProps) { - const [isPending, startTransition] = useTransition(); - const updateSearchParams = useUpdateSearchParams(); - - const onPaginationChange = (page: number, perPage: number) => { - startTransition(() => { - updateSearchParams({ - page: page.toString(), - perPage: perPage.toString(), - }); - }); - }; - - return ( -
- - {(table) => } - - - {isPending && ( -
- -
- )} -
- ); -} From a11440a7f36820bd9da6f89c1cf56a8bccca9ba4 Mon Sep 17 00:00:00 2001 From: pit Date: Mon, 9 Oct 2023 13:30:28 +0300 Subject: [PATCH 30/48] chore: tidy up --- .../documents/data-table-action-button.tsx | 52 ++++--------------- .../documents/data-table-action-dropdown.tsx | 16 +----- .../app/(dashboard)/admin/documents/page.tsx | 5 -- .../admin/users/[id]/documents/page.tsx | 3 -- .../admin/users/data-table-users.tsx | 8 +-- .../server-only/admin/get-all-documents.ts | 17 +----- packages/ui/primitives/combobox.tsx | 2 +- 7 files changed, 15 insertions(+), 88 deletions(-) delete mode 100644 apps/web/src/app/(dashboard)/admin/users/[id]/documents/page.tsx diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx index 7c1d42d2b..b1aa8efc5 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx @@ -2,11 +2,10 @@ import Link from 'next/link'; -import { Edit, Pencil, Share } from 'lucide-react'; +import { Edit } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { match } from 'ts-pattern'; -import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client'; +import { Document, Recipient, User } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; export type DataTableActionButtonProps = { @@ -23,43 +22,12 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { return null; } - const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); - - const isOwner = row.User.id === session.user.id; - const isRecipient = !!recipient; - const isDraft = row.status === DocumentStatus.DRAFT; - const isPending = row.status === DocumentStatus.PENDING; - const isComplete = row.status === DocumentStatus.COMPLETED; - const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; - - return match({ - isOwner, - isRecipient, - isDraft, - isPending, - isComplete, - isSigned, - }) - .with({ isOwner: true, isDraft: true }, () => ( - - )) - .with({ isRecipient: true, isPending: true, isSigned: false }, () => ( - - )) - .otherwise(() => ( - - )); + return ( + + ); }; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx index 72fdb4845..4788033b1 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx @@ -1,18 +1,6 @@ 'use client'; -import Link from 'next/link'; - -import { - Copy, - Download, - Edit, - History, - MoreHorizontal, - Pencil, - Share, - Trash2, - XCircle, -} from 'lucide-react'; +import { Copy, Download, History, MoreHorizontal, Trash2, XCircle } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { getFile } from '@documenso/lib/universal/upload/get-file'; @@ -42,8 +30,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = } const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); - - const isOwner = row.User.id === session.user.id; // const isRecipient = !!recipient; // const isDraft = row.status === DocumentStatus.DRAFT; // const isPending = row.status === DocumentStatus.PENDING; diff --git a/apps/web/src/app/(dashboard)/admin/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/documents/page.tsx index 6b5a0761c..2fbbcd4dc 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx @@ -1,4 +1,3 @@ -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { findDocuments } from '@documenso/lib/server-only/admin/get-all-documents'; import { DocumentsDataTable } from './data-table'; @@ -15,10 +14,6 @@ export default async function Documents({ searchParams = {} }: DocumentsPageProp const perPage = Number(searchParams.perPage) || 20; const results = await findDocuments({ - orderBy: { - column: 'createdAt', - direction: 'desc', - }, page, perPage, }); diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/documents/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/documents/page.tsx deleted file mode 100644 index e17dc611b..000000000 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/documents/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function UserDocuments() { - return

User docs

; -} diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index a598fc605..7c1ec0520 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -105,13 +105,7 @@ export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTa header: 'Documents', accessorKey: 'documents', cell: ({ row }) => { - return ( -
- - {row.original.Document.length} - -
- ); + return
{row.original.Document.length}
; }, }, { diff --git a/packages/lib/server-only/admin/get-all-documents.ts b/packages/lib/server-only/admin/get-all-documents.ts index a1abdb186..057b00afb 100644 --- a/packages/lib/server-only/admin/get-all-documents.ts +++ b/packages/lib/server-only/admin/get-all-documents.ts @@ -1,25 +1,12 @@ import { prisma } from '@documenso/prisma'; -import { Document } from '@documenso/prisma/client'; export interface FindDocumentsOptions { term?: string; page?: number; perPage?: number; - orderBy?: { - column: keyof Omit; - direction: 'asc' | 'desc'; - }; } -export const findDocuments = async ({ - term, - page = 1, - perPage = 10, - orderBy, -}: FindDocumentsOptions) => { - const orderByColumn = orderBy?.column ?? 'createdAt'; - const orderByDirection = orderBy?.direction ?? 'desc'; - +export const findDocuments = async ({ term, page = 1, perPage = 10 }: FindDocumentsOptions) => { const termFilters = !term ? undefined : ({ @@ -37,7 +24,7 @@ export const findDocuments = async ({ skip: Math.max(page - 1, 0) * perPage, take: perPage, orderBy: { - [orderByColumn]: orderByDirection, + createdAt: 'desc', }, include: { User: { diff --git a/packages/ui/primitives/combobox.tsx b/packages/ui/primitives/combobox.tsx index 6e566e188..90fdc7849 100644 --- a/packages/ui/primitives/combobox.tsx +++ b/packages/ui/primitives/combobox.tsx @@ -16,7 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitive type ComboboxProps = { listValues: string[]; - onChange: (values: string[]) => void; + onChange: (_values: string[]) => void; }; const Combobox = ({ listValues, onChange }: ComboboxProps) => { From 1299aa51eebb291cc7956a2276beec13c234848f Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 10 Oct 2023 11:44:16 +0300 Subject: [PATCH 31/48] chore: move fetching in data-table-users --- .../admin/users/data-table-users.tsx | 60 +++++++++++- .../admin/users/fetch-users.actions.ts | 9 ++ .../src/app/(dashboard)/admin/users/page.tsx | 14 +-- .../src/app/(dashboard)/admin/users/users.tsx | 91 ------------------- 4 files changed, 66 insertions(+), 108 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/admin/users/fetch-users.actions.ts delete mode 100644 apps/web/src/app/(dashboard)/admin/users/users.tsx diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index 7c1ec0520..456fda7cd 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useTransition } from 'react'; +import { useEffect, useState, useTransition } from 'react'; import Link from 'next/link'; @@ -11,6 +11,12 @@ import { Document, Role, Subscription } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useDebouncedValue } from '~/hooks/use-debounced-value'; + +import { search } from './fetch-users.actions'; interface User { id: number; @@ -29,15 +35,19 @@ type SubscriptionLite = Pick< type DocumentLite = Pick; type UsersDataTableProps = { - users: User[]; perPage: number; page: number; - totalPages: number; }; -export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTableProps) => { +export const UsersDataTable = ({ perPage, page }: UsersDataTableProps) => { + const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); + const [data, setData] = useState([]); + const [searchString, setSearchString] = useState(''); + const [totalPages, setTotalPages] = useState(0); + const debouncedSearchString = useDebouncedValue(searchString, 500); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -48,8 +58,48 @@ export const UsersDataTable = ({ users, perPage, page, totalPages }: UsersDataTa }); }; + useEffect(() => { + const fetchData = async () => { + try { + const result = await search(debouncedSearchString, page, perPage); + setData(result.users); + setTotalPages(result.totalPages); + + if (result.totalPages < page) { + startTransition(() => { + updateSearchParams({ + page: 1, + perPage, + }); + }); + } + } catch (err) { + throw new Error(err); + } + }; + + fetchData().catch(() => { + toast({ + title: 'Something went wrong', + description: 'Please try again', + variant: 'destructive', + }); + }); + }, [debouncedSearchString, page, perPage]); + + const handleChange = (e: React.ChangeEvent) => { + setSearchString(e.target.value); + }; + return (
+

Manage users

- +
); } diff --git a/apps/web/src/app/(dashboard)/admin/users/users.tsx b/apps/web/src/app/(dashboard)/admin/users/users.tsx deleted file mode 100644 index c27f71472..000000000 --- a/apps/web/src/app/(dashboard)/admin/users/users.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { useEffect, useState, useTransition } from 'react'; - -import { Loader } from 'lucide-react'; - -import { Document, Role, Subscription } from '@documenso/prisma/client'; -import { Button } from '@documenso/ui/primitives/button'; -import { Input } from '@documenso/ui/primitives/input'; - -import { useDebouncedValue } from '~/hooks/use-debounced-value'; - -import { UsersDataTable } from './data-table-users'; - -export type SubscriptionLite = Pick< - Subscription, - 'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd' ->; -export type DocumentLite = Pick; - -export type User = { - id: number; - name: string | null; - email: string; - roles: Role[]; - Subscription: SubscriptionLite[]; - Document: DocumentLite[]; -}; - -export type UsersProps = { - search: (_search: string) => Promise<{ users: User[]; totalPages: number }>; - perPage: number; - page: number; -}; - -export const Users = ({ search, perPage, page }: UsersProps) => { - const [data, setData] = useState([]); - const [totalPages, setTotalPages] = useState(0); - const [isPending, startTransition] = useTransition(); - const [searchString, setSearchString] = useState(''); - const debouncedSearchString = useDebouncedValue(searchString, 500); - - useEffect(() => { - const fetchData = async () => { - try { - const result = await search(debouncedSearchString); - setData(result.users); - setTotalPages(result.totalPages); - } catch (err) { - throw new Error(err); - } - }; - - fetchData(); - }, [debouncedSearchString, search]); - - const onSubmit = (e: React.FormEvent) => { - e.preventDefault(); - startTransition(async () => { - const result = await search(debouncedSearchString); - setData(result.users); - }); - }; - - const handleChange = (e: React.ChangeEvent) => { - setSearchString(e.target.value); - }; - - return ( - <> -
- - -
-
- {data.length === 0 || isPending ? ( -
- -
- ) : ( - - )} -
- - ); -}; From 8f4ba6eb8aedd2cd30055893ca948e98622c02c0 Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 10 Oct 2023 13:50:50 +0300 Subject: [PATCH 32/48] chore: self-review --- .../documents/data-table-action-button.tsx | 7 +-- .../documents/data-table-action-dropdown.tsx | 14 +---- .../admin/documents/data-table-title.tsx | 56 ------------------- .../admin/documents/data-table.tsx | 14 ++++- 4 files changed, 17 insertions(+), 74 deletions(-) delete mode 100644 apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx index b1aa8efc5..a3fed63ec 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx @@ -5,14 +5,11 @@ import Link from 'next/link'; import { Edit } from 'lucide-react'; import { useSession } from 'next-auth/react'; -import { Document, Recipient, User } from '@documenso/prisma/client'; +import { Document } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; export type DataTableActionButtonProps = { - row: Document & { - User: Pick; - Recipient: Recipient[]; - }; + row: Pick; }; export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx index 4788033b1..84d7b063f 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx @@ -18,7 +18,6 @@ import { export type DataTableActionDropdownProps = { row: Document & { User: Pick; - Recipient: Recipient[]; }; }; @@ -29,7 +28,6 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = return null; } - const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); // const isRecipient = !!recipient; // const isDraft = row.status === DocumentStatus.DRAFT; // const isPending = row.status === DocumentStatus.PENDING; @@ -39,15 +37,9 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = const onDownloadClick = async () => { let document: DocumentWithData | null = null; - if (!recipient) { - document = await trpc.document.getDocumentById.query({ - id: row.id, - }); - } else { - document = await trpc.document.getDocumentByToken.query({ - token: recipient.token, - }); - } + document = await trpc.document.getDocumentById.query({ + id: row.id, + }); const documentData = document?.documentData; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx deleted file mode 100644 index c04f9f13d..000000000 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-title.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client'; - -import Link from 'next/link'; - -import { useSession } from 'next-auth/react'; -import { match } from 'ts-pattern'; - -import { Document, Recipient, User } from '@documenso/prisma/client'; - -export type DataTableTitleProps = { - row: Document & { - User: Pick; - Recipient: Recipient[]; - }; -}; - -export const DataTableTitle = ({ row }: DataTableTitleProps) => { - const { data: session } = useSession(); - - if (!session) { - return null; - } - - const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email); - - const isOwner = row.User.id === session.user.id; - const isRecipient = !!recipient; - - return match({ - isOwner, - isRecipient, - }) - .with({ isOwner: true }, () => ( - - {row.title} - - )) - .with({ isRecipient: true }, () => ( - - {row.title} - - )) - .otherwise(() => ( - - {row.title} - - )); -}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx index 1d121742a..cef49b3df 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -19,7 +19,6 @@ import { LocaleDate } from '~/components/formatter/locale-date'; import { DataTableActionButton } from './data-table-action-button'; import { DataTableActionDropdown } from './data-table-action-dropdown'; -import { DataTableTitle } from './data-table-title'; export type DocumentsDataTableProps = { results: FindResultSet< @@ -60,7 +59,18 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { }, { header: 'Title', - cell: ({ row }) => , + accessorKey: 'title', + cell: ({ row }) => { + return ( + + {row.original.title} + + ); + }, }, { header: 'Owner', From 67629dd735dffc131f6243c81ddbb7ec54c5c03b Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 10 Oct 2023 13:57:07 +0300 Subject: [PATCH 33/48] chore: fix eslint issues --- .../(dashboard)/admin/documents/data-table-action-dropdown.tsx | 2 +- apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx index 84d7b063f..21650ffc5 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx @@ -4,7 +4,7 @@ import { Copy, Download, History, MoreHorizontal, Trash2, XCircle } from 'lucide import { useSession } from 'next-auth/react'; import { getFile } from '@documenso/lib/universal/upload/get-file'; -import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; +import { Document, DocumentStatus, User } from '@documenso/prisma/client'; import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { trpc } from '@documenso/trpc/client'; import { diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index 456fda7cd..737029a05 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -6,6 +6,7 @@ import Link from 'next/link'; import { Edit, Loader } from 'lucide-react'; +import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { Document, Role, Subscription } from '@documenso/prisma/client'; import { Button } from '@documenso/ui/primitives/button'; @@ -14,8 +15,6 @@ import { DataTablePagination } from '@documenso/ui/primitives/data-table-paginat import { Input } from '@documenso/ui/primitives/input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { useDebouncedValue } from '~/hooks/use-debounced-value'; - import { search } from './fetch-users.actions'; interface User { From 9e0d2818835da61312a78c0962be51aab81a6a90 Mon Sep 17 00:00:00 2001 From: pit Date: Tue, 10 Oct 2023 16:52:58 +0300 Subject: [PATCH 34/48] chore: feedback fix --- .../documents/data-table-action-button.tsx | 30 ------------------- .../documents/data-table-action-dropdown.tsx | 7 ----- .../admin/documents/data-table.tsx | 30 +++++++++++-------- 3 files changed, 17 insertions(+), 50 deletions(-) delete mode 100644 apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx deleted file mode 100644 index a3fed63ec..000000000 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-button.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import Link from 'next/link'; - -import { Edit } from 'lucide-react'; -import { useSession } from 'next-auth/react'; - -import { Document } from '@documenso/prisma/client'; -import { Button } from '@documenso/ui/primitives/button'; - -export type DataTableActionButtonProps = { - row: Pick; -}; - -export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => { - const { data: session } = useSession(); - - if (!session) { - return null; - } - - return ( - - ); -}; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx index 21650ffc5..a4a4b5ee4 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx @@ -1,7 +1,6 @@ 'use client'; import { Copy, Download, History, MoreHorizontal, Trash2, XCircle } from 'lucide-react'; -import { useSession } from 'next-auth/react'; import { getFile } from '@documenso/lib/universal/upload/get-file'; import { Document, DocumentStatus, User } from '@documenso/prisma/client'; @@ -22,12 +21,6 @@ export type DataTableActionDropdownProps = { }; export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { - const { data: session } = useSession(); - - if (!session) { - return null; - } - // const isRecipient = !!recipient; // const isDraft = row.status === DocumentStatus.DRAFT; // const isPending = row.status === DocumentStatus.PENDING; diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx index cef49b3df..8bda26ef4 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -4,33 +4,30 @@ import { useTransition } from 'react'; import Link from 'next/link'; -import { Loader } from 'lucide-react'; -import { useSession } from 'next-auth/react'; +import { Edit, Loader } from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { FindResultSet } from '@documenso/lib/types/find-result-set'; -import { Document, Recipient, User } from '@documenso/prisma/client'; +import { Document, User } from '@documenso/prisma/client'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; +import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; -import { DataTableActionButton } from './data-table-action-button'; import { DataTableActionDropdown } from './data-table-action-dropdown'; export type DocumentsDataTableProps = { results: FindResultSet< Document & { - Recipient: Recipient[]; User: Pick; } >; }; export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { - const { data: session } = useSession(); const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); @@ -44,10 +41,6 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { }); }; - if (!session) { - return null; - } - return (
{ {row.original.title} @@ -90,13 +89,18 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { { header: 'Status', accessorKey: 'status', - cell: ({ row }) => , + cell: ({ row }) => , }, { header: 'Actions', cell: ({ row }) => (
- +
), From e02ab7d256863cf9bbc8e659be71d7641e4815ed Mon Sep 17 00:00:00 2001 From: pit Date: Wed, 11 Oct 2023 12:32:33 +0300 Subject: [PATCH 35/48] chore: implement pr feedback --- .../app/(dashboard)/admin/users/[id]/page.tsx | 129 ++++++++++-------- packages/lib/server-only/admin/update-user.ts | 3 +- .../lib/server-only/user/get-all-users.ts | 4 +- packages/trpc/server/admin-router/router.ts | 16 +-- packages/trpc/server/profile-router/router.ts | 36 ++--- packages/trpc/server/trpc.ts | 30 ++++ packages/ui/primitives/combobox.tsx | 66 +++++---- 7 files changed, 154 insertions(+), 130 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 802f2ec0c..8c68f1270 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -3,21 +3,23 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader } from 'lucide-react'; -import { Controller, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Combobox } from '@documenso/ui/primitives/combobox'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; -import { FormErrorMessage } from '../../../../../components/form/form-error-message'; -import { - TUserFormSchema, - ZUserFormSchema, -} from '../../../../../providers/admin-user-profile-update.types'; +import { TUserFormSchema, ZUserFormSchema } from '~/providers/admin-user-profile-update.types'; export default function UserPage({ params }: { params: { id: number } }) { const { toast } = useToast(); @@ -34,21 +36,11 @@ export default function UserPage({ params }: { params: { id: number } }) { const user = result.data; - const roles = user?.roles; - let rolesArr: string[] = []; - - if (roles) { - rolesArr = Object.values(roles); - } + const roles = user?.roles ?? []; const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation(); - const { - register, - control, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ + const form = useForm({ resolver: zodResolver(ZUserFormSchema), values: { name: user?.name ?? '', @@ -85,42 +77,69 @@ export default function UserPage({ params }: { params: { id: number } }) { return (

Manage {user?.name}'s profile

-
-
- - - -
-
- - - -
-
- - ( - onChange(values)} /> - )} - /> - -
+ + +
+ ( + + + Name + + + + + + + )} + /> + ( + + + Email + + + + + + + )} + /> -
- -
- + ( + +
+ + Roles + + + onChange(values)} + /> + + +
+
+ )} + /> + +
+ +
+
+ +
); } diff --git a/packages/lib/server-only/admin/update-user.ts b/packages/lib/server-only/admin/update-user.ts index e4cc7b2bc..9013899a7 100644 --- a/packages/lib/server-only/admin/update-user.ts +++ b/packages/lib/server-only/admin/update-user.ts @@ -15,7 +15,7 @@ export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) }, }); - const updatedUser = await prisma.user.update({ + return await prisma.user.update({ where: { id, }, @@ -25,5 +25,4 @@ export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) roles, }, }); - return updatedUser; }; diff --git a/packages/lib/server-only/user/get-all-users.ts b/packages/lib/server-only/user/get-all-users.ts index babcc7ba1..a1ff2c929 100644 --- a/packages/lib/server-only/user/get-all-users.ts +++ b/packages/lib/server-only/user/get-all-users.ts @@ -2,7 +2,7 @@ import { Prisma } from '@prisma/client'; import { prisma } from '@documenso/prisma'; -type getAllUsersProps = { +type GetAllUsersProps = { username: string; email: string; page: number; @@ -14,7 +14,7 @@ export const findUsers = async ({ email = '', page = 1, perPage = 10, -}: getAllUsersProps) => { +}: GetAllUsersProps) => { const whereClause = Prisma.validator()({ OR: [ { diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 67556a251..666e3f085 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -1,24 +1,14 @@ import { TRPCError } from '@trpc/server'; -import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; import { updateUser } from '@documenso/lib/server-only/admin/update-user'; -import { authenticatedProcedure, router } from '../trpc'; +import { adminProcedure, router } from '../trpc'; import { ZUpdateProfileMutationByAdminSchema } from './schema'; export const adminRouter = router({ - updateUser: authenticatedProcedure + updateUser: adminProcedure .input(ZUpdateProfileMutationByAdminSchema) - .mutation(async ({ input, ctx }) => { - const isUserAdmin = isAdmin(ctx.user); - - if (!isUserAdmin) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'Not authorized to perform this action.', - }); - } - + .mutation(async ({ input }) => { const { id, name, email, roles } = input; try { diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 8d83528c0..0f6636650 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,13 +1,12 @@ import { TRPCError } from '@trpc/server'; -import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; 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 { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; -import { authenticatedProcedure, procedure, router } from '../trpc'; +import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc'; import { ZForgotPasswordFormSchema, ZResetPasswordFormSchema, @@ -17,29 +16,18 @@ import { } from './schema'; export const profileRouter = router({ - getUser: authenticatedProcedure - .input(ZRetrieveUserByIdQuerySchema) - .query(async ({ input, ctx }) => { - const isUserAdmin = isAdmin(ctx.user); + getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => { + try { + const { id } = input; - if (!isUserAdmin) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: 'Not authorized to perform this action.', - }); - } - - try { - const { id } = input; - - return await getUserById({ id }); - } catch (err) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to retrieve the specified account. Please try again.', - }); - } - }), + return await getUserById({ id }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the specified account. Please try again.', + }); + } + }), updateProfile: authenticatedProcedure .input(ZUpdateProfileMutationSchema) diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts index 91d2a239f..a382e3511 100644 --- a/packages/trpc/server/trpc.ts +++ b/packages/trpc/server/trpc.ts @@ -1,6 +1,8 @@ import { TRPCError, initTRPC } from '@trpc/server'; import SuperJSON from 'superjson'; +import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin'; + import { TrpcContext } from './context'; const t = initTRPC.context().create({ @@ -28,9 +30,37 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => { }); }); +export const adminMiddleware = t.middleware(async ({ ctx, next }) => { + if (!ctx.session || !ctx.user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You must be logged in to perform this action.', + }); + } + + const isUserAdmin = isAdmin(ctx.user); + + if (!isUserAdmin) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Not authorized to perform this action.', + }); + } + + return await next({ + ctx: { + ...ctx, + + user: ctx.user, + session: ctx.session, + }, + }); +}); + /** * Routers and Procedures */ export const router = t.router; export const procedure = t.procedure; export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware); +export const adminProcedure = t.procedure.use(adminMiddleware); diff --git a/packages/ui/primitives/combobox.tsx b/packages/ui/primitives/combobox.tsx index 90fdc7849..899ccd61d 100644 --- a/packages/ui/primitives/combobox.tsx +++ b/packages/ui/primitives/combobox.tsx @@ -44,40 +44,38 @@ const Combobox = ({ listValues, onChange }: ComboboxProps) => { }; return ( - <> - - - - - - - - No value found. - - {allRoles.map((value: string, i: number) => ( - handleSelect(value)}> - - {value} - - ))} - - - - - + + + + + + + + No value found. + + {allRoles.map((value: string, i: number) => ( + handleSelect(value)}> + + {value} + + ))} + + + + ); }; From bc9a6fa50aa67124232c80bbe8a84178151f629c Mon Sep 17 00:00:00 2001 From: pit Date: Wed, 11 Oct 2023 16:20:04 +0300 Subject: [PATCH 36/48] chore: implemented feedback --- .../admin/users/data-table-users.tsx | 54 ++++++------------- .../src/app/(dashboard)/admin/users/page.tsx | 9 +++- 2 files changed, 22 insertions(+), 41 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index 737029a05..b6b475ea2 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -13,9 +13,6 @@ import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Input } from '@documenso/ui/primitives/input'; -import { useToast } from '@documenso/ui/primitives/use-toast'; - -import { search } from './fetch-users.actions'; interface User { id: number; @@ -34,19 +31,27 @@ type SubscriptionLite = Pick< type DocumentLite = Pick; type UsersDataTableProps = { + users: User[]; + totalPages: number; perPage: number; page: number; }; -export const UsersDataTable = ({ perPage, page }: UsersDataTableProps) => { - const { toast } = useToast(); - +export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => { const [isPending, startTransition] = useTransition(); const updateSearchParams = useUpdateSearchParams(); - const [data, setData] = useState([]); const [searchString, setSearchString] = useState(''); - const [totalPages, setTotalPages] = useState(0); - const debouncedSearchString = useDebouncedValue(searchString, 500); + const debouncedSearchString = useDebouncedValue(searchString, 1000); + + useEffect(() => { + startTransition(() => { + updateSearchParams({ + search: debouncedSearchString, + page: 1, + perPage, + }); + }); + }, [debouncedSearchString]); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -57,35 +62,6 @@ export const UsersDataTable = ({ perPage, page }: UsersDataTableProps) => { }); }; - useEffect(() => { - const fetchData = async () => { - try { - const result = await search(debouncedSearchString, page, perPage); - setData(result.users); - setTotalPages(result.totalPages); - - if (result.totalPages < page) { - startTransition(() => { - updateSearchParams({ - page: 1, - perPage, - }); - }); - } - } catch (err) { - throw new Error(err); - } - }; - - fetchData().catch(() => { - toast({ - title: 'Something went wrong', - description: 'Please try again', - variant: 'destructive', - }); - }); - }, [debouncedSearchString, page, perPage]); - const handleChange = (e: React.ChangeEvent) => { setSearchString(e.target.value); }; @@ -174,7 +150,7 @@ export const UsersDataTable = ({ perPage, page }: UsersDataTableProps) => { }, }, ]} - data={data} + data={users} perPage={perPage} currentPage={page} totalPages={totalPages} diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index b11d4a4b2..686ce7669 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -1,20 +1,25 @@ import { UsersDataTable } from './data-table-users'; +import { search } from './fetch-users.actions'; type AdminManageUsersProps = { searchParams?: { + search?: string; page?: number; perPage?: number; }; }; -export default function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) { +export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) { const page = Number(searchParams.page) || 1; const perPage = Number(searchParams.perPage) || 10; + const searchString = searchParams.search || ''; + + const { users, totalPages } = await search(searchString, page, perPage); return (

Manage users

- +
); } From cc8077340270bcaabce29ed44594ef8494dec294 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 12 Oct 2023 11:44:16 +0300 Subject: [PATCH 37/48] chore: implement feedback --- packages/web-tests/e2e/test-auth-flow.spec.ts | 6 +++--- packages/web-tests/package.json | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/web-tests/e2e/test-auth-flow.spec.ts b/packages/web-tests/e2e/test-auth-flow.spec.ts index 3d1d4cd39..01f923c35 100644 --- a/packages/web-tests/e2e/test-auth-flow.spec.ts +++ b/packages/web-tests/e2e/test-auth-flow.spec.ts @@ -10,9 +10,9 @@ test.use({ storageState: { cookies: [], origins: [] } }); */ test.describe.configure({ mode: 'serial' }); -const username = 'testuser'; -const email = 'test-user@documenso.com'; -const password = 'password'; +const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME; +const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL; +const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD; test('user can sign up with email and password', async ({ page }: { page: Page }) => { await page.goto('/signup'); diff --git a/packages/web-tests/package.json b/packages/web-tests/package.json index fdf2f3268..92cfd169d 100644 --- a/packages/web-tests/package.json +++ b/packages/web-tests/package.json @@ -1,6 +1,7 @@ { - "name": "web-tests", + "name": "@documenso/app-tests", "version": "1.0.0", + "license": "to-update", "description": "", "main": "index.js", "scripts": { @@ -9,7 +10,6 @@ }, "keywords": [], "author": "", - "license": "ISC", "devDependencies": { "@playwright/test": "^1.18.1", "@types/node": "^20.8.2", From c0dd57a4d2a5bf4edb2f54b6429039657407f4e4 Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 12 Oct 2023 12:19:23 +0300 Subject: [PATCH 38/48] chore: implement feedback --- .github/workflows/e2e-tests.yml | 2 +- package-lock.json | 54 +++++++++++-------- packages/{web-tests => app-tests}/.gitignore | 0 .../e2e/test-auth-flow.spec.ts | 2 +- .../{web-tests => app-tests}/package.json | 0 .../playwright.config.ts | 0 packages/lib/server-only/user/delete-user.ts | 8 +-- 7 files changed, 38 insertions(+), 28 deletions(-) rename packages/{web-tests => app-tests}/.gitignore (100%) rename packages/{web-tests => app-tests}/e2e/test-auth-flow.spec.ts (98%) rename packages/{web-tests => app-tests}/package.json (100%) rename packages/{web-tests => app-tests}/playwright.config.ts (100%) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f39e842d3..be4d611da 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -29,7 +29,7 @@ jobs: - name: Install dependencies run: npm ci - name: Copy env - run: cp .env.example .env + run: cp .env.example .env.local - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Generate Prisma Client diff --git a/package-lock.json b/package-lock.json index 4bbe127c7..2ce917256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1848,6 +1848,10 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@documenso/app-tests": { + "resolved": "packages/app-tests", + "link": true + }, "node_modules/@documenso/ee": { "resolved": "packages/ee", "link": true @@ -19418,6 +19422,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "dev": true + }, "node_modules/unified": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", @@ -19870,10 +19880,6 @@ "node": ">= 8" } }, - "node_modules/web-tests": { - "resolved": "packages/web-tests", - "link": true - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -20106,6 +20112,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/app-tests": { + "name": "@documenso/app-tests", + "version": "1.0.0", + "license": "to-update", + "dependencies": { + "start-server-and-test": "^2.0.1" + }, + "devDependencies": { + "@documenso/web": "*", + "@playwright/test": "^1.18.1", + "@types/node": "^20.8.2" + } + }, + "packages/app-tests/node_modules/@types/node": { + "version": "20.8.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz", + "integrity": "sha512-ZVPnqU58giiCjSxjVUESDtdPk4QR5WQhhINbc9UBrKLU68MX5BF6kbQzTrkwbolyr0X8ChBpXfavr5mZFKZQ5A==", + "dev": true, + "dependencies": { + "undici-types": "~5.25.1" + } + }, "packages/ee": { "name": "@documenso/ee", "version": "1.0.0", @@ -20326,24 +20354,6 @@ "react": "18.2.0", "typescript": "^5.1.6" } - }, - "packages/web-tests": { - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "start-server-and-test": "^2.0.1" - }, - "devDependencies": { - "@documenso/web": "*", - "@playwright/test": "^1.18.1", - "@types/node": "^20.8.2" - } - }, - "packages/web-tests/node_modules/@types/node": { - "version": "20.8.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.2.tgz", - "integrity": "sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w==", - "dev": true } } } diff --git a/packages/web-tests/.gitignore b/packages/app-tests/.gitignore similarity index 100% rename from packages/web-tests/.gitignore rename to packages/app-tests/.gitignore diff --git a/packages/web-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts similarity index 98% rename from packages/web-tests/e2e/test-auth-flow.spec.ts rename to packages/app-tests/e2e/test-auth-flow.spec.ts index 01f923c35..e486b2bd5 100644 --- a/packages/web-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -48,7 +48,7 @@ test('user can login with user and password', async ({ page }: { page: Page }) = test.afterAll('Teardown', async () => { try { - await deleteUser(username); + await deleteUser(email); } catch (e) { throw new Error(`Error deleting user: ${e}`); } diff --git a/packages/web-tests/package.json b/packages/app-tests/package.json similarity index 100% rename from packages/web-tests/package.json rename to packages/app-tests/package.json diff --git a/packages/web-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts similarity index 100% rename from packages/web-tests/playwright.config.ts rename to packages/app-tests/playwright.config.ts diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index 901a8a3d7..c2a7089b5 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -1,16 +1,16 @@ import { prisma } from '@documenso/prisma'; -export const deleteUser = async (name: string) => { +export const deleteUser = async (email: string) => { const user = await prisma.user.findFirst({ where: { - name: { - contains: name, + email: { + contains: email, }, }, }); if (!user) { - throw new Error(`User with name ${name} not found`); + throw new Error(`User with email ${email} not found`); } return await prisma.user.delete({ From 55301a9d53e252020ef38cd80e17d717b3827aec Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 12 Oct 2023 12:49:39 +0300 Subject: [PATCH 39/48] chore: revert back env file name --- .github/workflows/e2e-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index be4d611da..f39e842d3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -29,7 +29,7 @@ jobs: - name: Install dependencies run: npm ci - name: Copy env - run: cp .env.example .env.local + run: cp .env.example .env - name: Install Playwright Browsers run: npx playwright install --with-deps - name: Generate Prisma Client From 7927b87259ae7a5983e57e0cd4020dee1b0def2c Mon Sep 17 00:00:00 2001 From: pit Date: Thu, 12 Oct 2023 17:07:54 +0300 Subject: [PATCH 40/48] chore: polished code --- .../admin/documents/data-table.tsx | 40 ++++--------------- .../app/(dashboard)/admin/users/[id]/page.tsx | 12 ++---- 2 files changed, 10 insertions(+), 42 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx index 8bda26ef4..3b098c2fd 100644 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -4,21 +4,18 @@ import { useTransition } from 'react'; import Link from 'next/link'; -import { Edit, Loader } from 'lucide-react'; +import { Loader } from 'lucide-react'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { FindResultSet } from '@documenso/lib/types/find-result-set'; import { Document, User } from '@documenso/prisma/client'; import { Avatar, AvatarFallback } from '@documenso/ui/primitives/avatar'; -import { Button } from '@documenso/ui/primitives/button'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DocumentStatus } from '~/components/formatter/document-status'; import { LocaleDate } from '~/components/formatter/locale-date'; -import { DataTableActionDropdown } from './data-table-action-dropdown'; - export type DocumentsDataTableProps = { results: FindResultSet< Document & { @@ -54,21 +51,7 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { header: 'Title', accessorKey: 'title', cell: ({ row }) => { - return ( - - {row.original.title} - - ); + return
{row.original.title}
; }, }, { @@ -86,25 +69,16 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { ); }, }, + { + header: 'Last updated', + accessorKey: 'updatedAt', + cell: ({ row }) => , + }, { header: 'Status', accessorKey: 'status', cell: ({ row }) => , }, - { - header: 'Actions', - cell: ({ row }) => ( -
- - -
- ), - }, ]} data={results.data} perPage={results.perPage} diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index 8c68f1270..e1535c16e 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -85,9 +85,7 @@ export default function UserPage({ params }: { params: { id: number } }) { name="name" render={({ field }) => ( - - Name - + Name @@ -100,9 +98,7 @@ export default function UserPage({ params }: { params: { id: number } }) { name="email" render={({ field }) => ( - - Email - + Email @@ -117,9 +113,7 @@ export default function UserPage({ params }: { params: { id: number } }) { render={({ field: { onChange } }) => (
- - Roles - + Roles Date: Fri, 13 Oct 2023 12:45:39 +1100 Subject: [PATCH 41/48] fix: add cascade delete for share links --- .../app/(dashboard)/documents/data-table-action-dropdown.tsx | 4 ++-- .../migration.sql | 5 +++++ packages/prisma/schema.prisma | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 packages/prisma/migrations/20231013012902_add_document_share_link_delete_cascade/migration.sql 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 2fb06833b..51b94a211 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 @@ -61,7 +61,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = const isOwner = row.User.id === session.user.id; // const isRecipient = !!recipient; - // const isDraft = row.status === DocumentStatus.DRAFT; + const isDraft = row.status === DocumentStatus.DRAFT; // const isPending = row.status === DocumentStatus.PENDING; const isComplete = row.status === DocumentStatus.COMPLETED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; @@ -166,7 +166,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = Resend - + {isCreatingShareLink ? ( ) : ( diff --git a/packages/prisma/migrations/20231013012902_add_document_share_link_delete_cascade/migration.sql b/packages/prisma/migrations/20231013012902_add_document_share_link_delete_cascade/migration.sql new file mode 100644 index 000000000..ca4142a1f --- /dev/null +++ b/packages/prisma/migrations/20231013012902_add_document_share_link_delete_cascade/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "DocumentShareLink" DROP CONSTRAINT "DocumentShareLink_documentId_fkey"; + +-- AddForeignKey +ALTER TABLE "DocumentShareLink" ADD CONSTRAINT "DocumentShareLink_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index c4f034ba2..340b98528 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -219,7 +219,7 @@ model DocumentShareLink { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - document Document @relation(fields: [documentId], references: [id]) + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) @@unique([documentId, email]) } From e1bee1591f55cca0f424cb17d55866e3c1e0a7b5 Mon Sep 17 00:00:00 2001 From: pit Date: Fri, 13 Oct 2023 11:48:52 +0300 Subject: [PATCH 42/48] chore: implemented feedback --- .../documents/data-table-action-dropdown.tsx | 95 ------------------- .../server-only/admin/get-all-documents.ts | 7 +- .../lib/server-only/user/get-all-users.ts | 3 +- 3 files changed, 5 insertions(+), 100 deletions(-) delete mode 100644 apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx diff --git a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx deleted file mode 100644 index a4a4b5ee4..000000000 --- a/apps/web/src/app/(dashboard)/admin/documents/data-table-action-dropdown.tsx +++ /dev/null @@ -1,95 +0,0 @@ -'use client'; - -import { Copy, Download, History, MoreHorizontal, Trash2, XCircle } from 'lucide-react'; - -import { getFile } from '@documenso/lib/universal/upload/get-file'; -import { Document, DocumentStatus, User } from '@documenso/prisma/client'; -import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; -import { trpc } from '@documenso/trpc/client'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from '@documenso/ui/primitives/dropdown-menu'; - -export type DataTableActionDropdownProps = { - row: Document & { - User: Pick; - }; -}; - -export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => { - // const isRecipient = !!recipient; - // const isDraft = row.status === DocumentStatus.DRAFT; - // const isPending = row.status === DocumentStatus.PENDING; - const isComplete = row.status === DocumentStatus.COMPLETED; - // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; - - const onDownloadClick = async () => { - let document: DocumentWithData | null = null; - - document = await trpc.document.getDocumentById.query({ - id: row.id, - }); - - const documentData = document?.documentData; - - if (!documentData) { - return; - } - - const documentBytes = await getFile(documentData); - - const blob = new Blob([documentBytes], { - type: 'application/pdf', - }); - - const link = window.document.createElement('a'); - - link.href = window.URL.createObjectURL(blob); - link.download = row.title || 'document.pdf'; - - link.click(); - - window.URL.revokeObjectURL(link.href); - }; - - return ( - - - - - - - Action - - - - Download - - - - - Duplicate - - - - - Void - - - - - Delete - - - - - Resend - - - - ); -}; diff --git a/packages/lib/server-only/admin/get-all-documents.ts b/packages/lib/server-only/admin/get-all-documents.ts index 057b00afb..cca1935a3 100644 --- a/packages/lib/server-only/admin/get-all-documents.ts +++ b/packages/lib/server-only/admin/get-all-documents.ts @@ -1,4 +1,5 @@ import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; export interface FindDocumentsOptions { term?: string; @@ -7,14 +8,14 @@ export interface FindDocumentsOptions { } export const findDocuments = async ({ term, page = 1, perPage = 10 }: FindDocumentsOptions) => { - const termFilters = !term + const termFilters: Prisma.DocumentWhereInput | undefined = !term ? undefined - : ({ + : { title: { contains: term, mode: 'insensitive', }, - } as const); + }; const [data, count] = await Promise.all([ prisma.document.findMany({ diff --git a/packages/lib/server-only/user/get-all-users.ts b/packages/lib/server-only/user/get-all-users.ts index a1ff2c929..f7d431fa3 100644 --- a/packages/lib/server-only/user/get-all-users.ts +++ b/packages/lib/server-only/user/get-all-users.ts @@ -1,6 +1,5 @@ -import { Prisma } from '@prisma/client'; - import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; type GetAllUsersProps = { username: string; From 901e83af58809b2bc6a776a760d794fa1e481ff9 Mon Sep 17 00:00:00 2001 From: pit Date: Fri, 13 Oct 2023 12:16:07 +0300 Subject: [PATCH 43/48] chore: implemented feedback --- packages/lib/server-only/user/get-all-users.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/lib/server-only/user/get-all-users.ts b/packages/lib/server-only/user/get-all-users.ts index f7d431fa3..71e670e7d 100644 --- a/packages/lib/server-only/user/get-all-users.ts +++ b/packages/lib/server-only/user/get-all-users.ts @@ -33,21 +33,8 @@ export const findUsers = async ({ const [users, count] = await Promise.all([ await prisma.user.findMany({ - select: { - id: true, - name: true, - email: true, - roles: true, - Subscription: { - select: { - id: true, - status: true, - planId: true, - priceId: true, - createdAt: true, - periodEnd: true, - }, - }, + include: { + Subscription: true, Document: { select: { id: true, From 4d5275f915a2a6391cfccd0c57779519efd094ab Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 13 Oct 2023 23:33:40 +1100 Subject: [PATCH 44/48] fix: create custom pricing table --- .../settings/billing/billing-plans.tsx | 133 ++++++++++++++++++ .../billing/billing-portal-button.tsx | 1 + .../billing/create-billing-portal.action.ts | 36 +---- .../billing/create-checkout.action.ts | 59 ++++++++ .../app/(dashboard)/settings/billing/page.tsx | 19 +-- .../stripe/get-checkout-session.ts | 31 ++++ .../stripe/get-prices-by-interval.ts | 40 ++++++ packages/lib/server-only/stripe/index.ts | 1 + packages/lib/server-only/stripe/stripe.d.ts | 7 + .../lib/universal/stripe/to-human-price.ts | 3 + 10 files changed, 287 insertions(+), 43 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx create mode 100644 apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts create mode 100644 packages/ee/server-only/stripe/get-checkout-session.ts create mode 100644 packages/ee/server-only/stripe/get-prices-by-interval.ts create mode 100644 packages/lib/server-only/stripe/stripe.d.ts create mode 100644 packages/lib/universal/stripe/to-human-price.ts diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx new file mode 100644 index 000000000..ba4c0f818 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-plans.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useState } from 'react'; + +import { AnimatePresence, motion } from 'framer-motion'; + +import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; +import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; +import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price'; +import { Button } from '@documenso/ui/primitives/button'; +import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card'; +import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { createCheckout } from './create-checkout.action'; + +type Interval = keyof PriceIntervals; + +const INTERVALS: Interval[] = ['day', 'week', 'month', 'year']; + +// eslint-disable-next-line @typescript-eslint/consistent-type-assertions +const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval); + +const FRIENDLY_INTERVALS: Record = { + day: 'Daily', + week: 'Weekly', + month: 'Monthly', + year: 'Yearly', +}; + +const MotionCard = motion(Card); + +export type BillingPlansProps = { + prices: PriceIntervals; +}; + +export const BillingPlans = ({ prices }: BillingPlansProps) => { + const { toast } = useToast(); + + const isMounted = useIsMounted(); + + const [interval, setInterval] = useState('month'); + const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false); + + const onSubscribeClick = async (priceId: string) => { + try { + setIsFetchingCheckoutSession(true); + + const url = await createCheckout({ priceId }); + + if (!url) { + throw new Error('Unable to create session'); + } + + window.open(url); + } catch (_err) { + toast({ + title: 'Something went wrong', + description: 'An error occurred while trying to create a checkout session.', + variant: 'destructive', + }); + } finally { + setIsFetchingCheckoutSession(false); + } + }; + + return ( +
+ isInterval(value) && setInterval(value)}> + + {INTERVALS.map( + (interval) => + prices[interval].length > 0 && ( + + {FRIENDLY_INTERVALS[interval]} + + ), + )} + + + +
+ + {prices[interval].map((price) => ( + + + {price.product.name} + +
+ ${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '} + per {interval} +
+ +
+ {price.product.description} +
+ + {price.product.features && price.product.features.length > 0 && ( +
+
Includes:
+ +
    + {price.product.features.map((feature, index) => ( +
  • + {feature.name} +
  • + ))} +
+
+ )} + +
+ + + + + ))} + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx index 994f7c221..9add70263 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx @@ -21,6 +21,7 @@ export default function BillingPortalButton() { try { const sessionUrl = await createBillingPortal(); + if (!sessionUrl) { throw new Error('NO_SESSION'); } diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts index 331943648..cef36ee3f 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -8,10 +8,9 @@ import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-se import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; -import { prisma } from '@documenso/prisma'; export const createBillingPortal = async () => { - const user = await getRequiredServerComponentSession(); + const { user } = await getRequiredServerComponentSession(); const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); @@ -42,39 +41,6 @@ export const createBillingPortal = async () => { }); } - const stripeCustomerSubscriptions = stripeCustomer.subscriptions?.data ?? []; - - // Create a free subscription for user if it does not exist. - if (!existingSubscription && stripeCustomerSubscriptions.length === 0) { - const newSubscription = await stripe.subscriptions.create({ - customer: stripeCustomer.id, - items: [ - { - plan: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID, - }, - ], - }); - - await prisma.subscription.upsert({ - where: { - userId: user.id, - customerId: stripeCustomer.id, - }, - create: { - userId: user.id, - customerId: stripeCustomer.id, - planId: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID, - periodEnd: new Date(newSubscription.current_period_end * 1000), - status: 'ACTIVE', - }, - update: { - planId: process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID, - periodEnd: new Date(newSubscription.current_period_end * 1000), - status: 'ACTIVE', - }, - }); - } - return getPortalSession({ customerId: stripeCustomer.id, returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, diff --git a/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts new file mode 100644 index 000000000..f556133f0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/create-checkout.action.ts @@ -0,0 +1,59 @@ +'use server'; + +import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session'; +import { + getStripeCustomerByEmail, + getStripeCustomerById, +} from '@documenso/ee/server-only/stripe/get-customer'; +import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session'; +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; +import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; +import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; + +export type CreateCheckoutOptions = { + priceId: string; +}; + +export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => { + const { user } = await getRequiredServerComponentSession(); + + const existingSubscription = await getSubscriptionByUserId({ userId: user.id }); + + let stripeCustomer: Stripe.Customer | null = null; + + // Find the Stripe customer for the current user subscription. + if (existingSubscription) { + stripeCustomer = await getStripeCustomerById(existingSubscription.customerId); + + if (!stripeCustomer) { + throw new Error('Missing Stripe customer for subscription'); + } + + return getPortalSession({ + customerId: stripeCustomer.id, + returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, + }); + } + + // Fallback to check if a Stripe customer already exists for the current user email. + if (!stripeCustomer) { + stripeCustomer = await getStripeCustomerByEmail(user.email); + } + + // Create a Stripe customer if it does not exist for the current user. + if (!stripeCustomer) { + stripeCustomer = await stripe.customers.create({ + name: user.name ?? undefined, + email: user.email, + metadata: { + userId: user.id, + }, + }); + } + + return getCheckoutSession({ + customerId: stripeCustomer.id, + priceId, + returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`, + }); +}; diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index e48a2a40f..58fa6e5b7 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -2,13 +2,15 @@ import { redirect } from 'next/navigation'; import { match } from 'ts-pattern'; +import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; -import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag'; +import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; import { LocaleDate } from '~/components/formatter/locale-date'; +import { BillingPlans } from './billing-plans'; import BillingPortalButton from './billing-portal-button'; export default async function BillingSettingsPage() { @@ -21,7 +23,10 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - const subscription = await getSubscriptionByUserId({ userId: user.id }); + const [subscription, prices] = await Promise.all([ + getSubscriptionByUserId({ userId: user.id }), + getPricesByInterval(), + ]); let subscriptionProduct: Stripe.Product | null = null; @@ -33,16 +38,13 @@ export default async function BillingSettingsPage() { subscriptionProduct = foundSubscriptionProduct ?? null; } - const isMissingOrInactiveOrFreePlan = - !subscription || - subscription.status === 'INACTIVE' || - subscription?.planId === process.env.NEXT_PUBLIC_STRIPE_FREE_PLAN_ID; + const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; return (

Billing

-
+
{isMissingOrInactiveOrFreePlan && (

You are currently on the Free Plan. @@ -61,6 +63,7 @@ export default async function BillingSettingsPage() { ) : ( You currently have an active plan )} + {subscription.periodEnd && ( {' '} @@ -88,7 +91,7 @@ export default async function BillingSettingsPage() {


- + {isMissingOrInactiveOrFreePlan ? : }
); } diff --git a/packages/ee/server-only/stripe/get-checkout-session.ts b/packages/ee/server-only/stripe/get-checkout-session.ts new file mode 100644 index 000000000..a99ecd5f9 --- /dev/null +++ b/packages/ee/server-only/stripe/get-checkout-session.ts @@ -0,0 +1,31 @@ +'use server'; + +import { stripe } from '@documenso/lib/server-only/stripe'; + +export type GetCheckoutSessionOptions = { + customerId: string; + priceId: string; + returnUrl: string; +}; + +export const getCheckoutSession = async ({ + customerId, + priceId, + returnUrl, +}: GetCheckoutSessionOptions) => { + 'use server'; + + const session = await stripe.checkout.sessions.create({ + customer: customerId, + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + success_url: `${returnUrl}?success=true`, + cancel_url: `${returnUrl}?canceled=true`, + }); + + return session.url; +}; diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts new file mode 100644 index 000000000..5dfc3d7ea --- /dev/null +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -0,0 +1,40 @@ +import Stripe from 'stripe'; + +import { stripe } from '@documenso/lib/server-only/stripe'; + +// Utility type to handle usage of the `expand` option. +type PriceWithProduct = Stripe.Price & { product: Stripe.Product }; + +export type PriceIntervals = Record; + +export const getPricesByInterval = async () => { + const { data: prices } = await stripe.prices.search({ + query: `active:'true' type:'recurring'`, + expand: ['data.product'], + limit: 100, + }); + + const intervals: PriceIntervals = { + day: [], + week: [], + month: [], + year: [], + }; + + // Add each price to the correct interval. + for (const price of prices) { + if (price.recurring?.interval) { + // We use `expand` to get the product, but it's not typed as part of the Price type. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + intervals[price.recurring.interval].push(price as PriceWithProduct); + } + } + + // Order all prices by unit_amount. + intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount)); + intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount)); + intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount)); + intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount)); + + return intervals; +}; diff --git a/packages/lib/server-only/stripe/index.ts b/packages/lib/server-only/stripe/index.ts index 505beaec8..4c3669408 100644 --- a/packages/lib/server-only/stripe/index.ts +++ b/packages/lib/server-only/stripe/index.ts @@ -1,3 +1,4 @@ +/// import Stripe from 'stripe'; export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', { diff --git a/packages/lib/server-only/stripe/stripe.d.ts b/packages/lib/server-only/stripe/stripe.d.ts new file mode 100644 index 000000000..51ea902ea --- /dev/null +++ b/packages/lib/server-only/stripe/stripe.d.ts @@ -0,0 +1,7 @@ +declare module 'stripe' { + namespace Stripe { + interface Product { + features?: Array<{ name: string }>; + } + } +} diff --git a/packages/lib/universal/stripe/to-human-price.ts b/packages/lib/universal/stripe/to-human-price.ts new file mode 100644 index 000000000..a839c5fba --- /dev/null +++ b/packages/lib/universal/stripe/to-human-price.ts @@ -0,0 +1,3 @@ +export const toHumanPrice = (price: number) => { + return Number(price / 100).toFixed(2); +}; From ede9eb052dd4fc96ea90dac983c0484110dc9feb Mon Sep 17 00:00:00 2001 From: Mythie Date: Fri, 13 Oct 2023 23:56:11 +1100 Subject: [PATCH 45/48] fix: named exports --- .../(dashboard)/settings/billing/billing-portal-button.tsx | 4 ++-- apps/web/src/app/(dashboard)/settings/billing/page.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx index 9add70263..8fd78cae3 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx @@ -7,7 +7,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { createBillingPortal } from './create-billing-portal.action'; -export default function BillingPortalButton() { +export const BillingPortalButton = () => { const { toast } = useToast(); const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false); @@ -52,4 +52,4 @@ export default function BillingPortalButton() { Manage Subscription ); -} +}; diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index 58fa6e5b7..ce41f4f6d 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -11,7 +11,7 @@ import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription import { LocaleDate } from '~/components/formatter/locale-date'; import { BillingPlans } from './billing-plans'; -import BillingPortalButton from './billing-portal-button'; +import { BillingPortalButton } from './billing-portal-button'; export default async function BillingSettingsPage() { const { user } = await getRequiredServerComponentSession(); From a6e13faf7bb7c18ed7a38f918fe1c28c5f4cfa83 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 13 Oct 2023 13:08:39 +0000 Subject: [PATCH 46/48] fix: quick tweaks --- .../app/(dashboard)/admin/users/[id]/page.tsx | 4 +-- .../admin/users/data-table-users.tsx | 30 +++++-------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx index e1535c16e..790177c8a 100644 --- a/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -25,7 +25,7 @@ export default function UserPage({ params }: { params: { id: number } }) { const { toast } = useToast(); const router = useRouter(); - const result = trpc.profile.getUser.useQuery( + const { data: user } = trpc.profile.getUser.useQuery( { id: Number(params.id), }, @@ -34,8 +34,6 @@ export default function UserPage({ params }: { params: { id: number } }) { }, ); - const user = result.data; - const roles = user?.roles ?? []; const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation(); diff --git a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx index b6b475ea2..1840f5a44 100644 --- a/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -95,19 +95,7 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa { header: 'Roles', accessorKey: 'roles', - cell: ({ row }) => { - return ( - <> - {row.original.roles.map((role: string, i: number) => { - return ( - - {role} {} - - ); - })} - - ); - }, + cell: ({ row }) => row.original.roles.join(', '), }, { header: 'Subscription', @@ -134,18 +122,16 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa }, }, { - header: 'Edit', + header: '', accessorKey: 'edit', cell: ({ row }) => { return ( -
- -
+ ); }, }, From 442b089d7f2e8f0a4eaa8c8b416cd70414d81ccc Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sat, 14 Oct 2023 00:20:11 +1100 Subject: [PATCH 47/48] fix: style updates --- .github/workflows/e2e-tests.yml | 2 +- packages/app-tests/e2e/test-auth-flow.spec.ts | 2 +- packages/lib/server-only/user/delete-user.ts | 6 +++++- packages/prisma/schema.prisma | 16 ++++++++-------- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f39e842d3..8f0e7bb19 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -35,7 +35,7 @@ jobs: - name: Generate Prisma Client run: npm run prisma:generate -w @documenso/prisma - name: Create the database - run: npm run prisma:migrate-dev -w @documenso/prisma + run: npm run prisma:migrate-dev - name: Run Playwright tests run: npm run ci - uses: actions/upload-artifact@v3 diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts index e486b2bd5..1221dbf83 100644 --- a/packages/app-tests/e2e/test-auth-flow.spec.ts +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -48,7 +48,7 @@ test('user can login with user and password', async ({ page }: { page: Page }) = test.afterAll('Teardown', async () => { try { - await deleteUser(email); + await deleteUser({ email }); } catch (e) { throw new Error(`Error deleting user: ${e}`); } diff --git a/packages/lib/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts index c2a7089b5..64a8eb704 100644 --- a/packages/lib/server-only/user/delete-user.ts +++ b/packages/lib/server-only/user/delete-user.ts @@ -1,6 +1,10 @@ import { prisma } from '@documenso/prisma'; -export const deleteUser = async (email: string) => { +export type DeleteUserOptions = { + email: string; +} + +export const deleteUser = async ({ email }: DeleteUserOptions) => { const user = await prisma.user.findFirst({ where: { email: { diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4ea322694..340b98528 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -110,10 +110,10 @@ model Document { Field Field[] ShareLink DocumentShareLink[] documentDataId String - documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade) + documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade) documentMeta DocumentMeta? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt @@unique([documentDataId]) } @@ -133,11 +133,11 @@ model DocumentData { } model DocumentMeta { - id String @id @default(cuid()) - subject String? - message String? - documentId Int @unique - document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + subject String? + message String? + documentId Int @unique + document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) } enum ReadStatus { From 0d026f347674c28b1070431dd30a485e81af3612 Mon Sep 17 00:00:00 2001 From: Mythie Date: Sat, 14 Oct 2023 13:02:36 +1100 Subject: [PATCH 48/48] fix: filter out inactive products --- .../ee/server-only/stripe/get-prices-by-interval.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/ee/server-only/stripe/get-prices-by-interval.ts b/packages/ee/server-only/stripe/get-prices-by-interval.ts index 5dfc3d7ea..f621425cc 100644 --- a/packages/ee/server-only/stripe/get-prices-by-interval.ts +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -8,12 +8,21 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product }; export type PriceIntervals = Record; export const getPricesByInterval = async () => { - const { data: prices } = await stripe.prices.search({ + let { data: prices } = await stripe.prices.search({ query: `active:'true' type:'recurring'`, expand: ['data.product'], limit: 100, }); + prices = prices.filter((price) => { + // We use `expand` to get the product, but it's not typed as part of the Price type. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const product = price.product as Stripe.Product; + + // Filter out prices for products that are not active. + return product.active; + }); + const intervals: PriceIntervals = { day: [], week: [],