diff --git a/.env.example b/.env.example index 065976bc5..7bd71c04b 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" @@ -68,6 +73,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/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 000000000..8f0e7bb19 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,51 @@ +name: Playwright Tests +on: + push: + branches: [feat/refresh] + pull_request: + branches: [feat/refresh] +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 + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + - 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: Generate Prisma Client + run: npm run prisma:generate -w @documenso/prisma + - name: Create the database + run: npm run prisma:migrate-dev + - 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 + 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 }} diff --git a/apps/marketing/process-env.d.ts b/apps/marketing/process-env.d.ts index 3dfdcb30f..942007d17 100644 --- a/apps/marketing/process-env.d.ts +++ b/apps/marketing/process-env.d.ts @@ -7,6 +7,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 4149423dd..f775cb7d8 100644 --- a/apps/web/process-env.d.ts +++ b/apps/web/process-env.d.ts @@ -7,6 +7,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)/admin/documents/data-table.tsx b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx new file mode 100644 index 000000000..3b098c2fd --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/data-table.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useTransition } from 'react'; + +import Link from 'next/link'; + +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 { 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'; + +export type DocumentsDataTableProps = { + results: FindResultSet< + Document & { + User: Pick; + } + >; +}; + +export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { + const [isPending, startTransition] = useTransition(); + + const updateSearchParams = useUpdateSearchParams(); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + return ( +
+ , + }, + { + header: 'Title', + accessorKey: 'title', + cell: ({ row }) => { + return
{row.original.title}
; + }, + }, + { + header: 'Owner', + accessorKey: 'owner', + cell: ({ row }) => { + return ( + + + + {row.original.User.name} + + + + ); + }, + }, + { + header: 'Last updated', + accessorKey: 'updatedAt', + cell: ({ row }) => , + }, + { + header: 'Status', + accessorKey: 'status', + 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..2fbbcd4dc --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/documents/page.tsx @@ -0,0 +1,29 @@ +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 page = Number(searchParams.page) || 1; + const perPage = Number(searchParams.perPage) || 20; + + const results = await findDocuments({ + 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 3b87a9b13..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'; @@ -37,10 +37,40 @@ 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/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]/page.tsx b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx new file mode 100644 index 000000000..790177c8a --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/[id]/page.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +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 { useToast } from '@documenso/ui/primitives/use-toast'; + +import { TUserFormSchema, ZUserFormSchema } from '~/providers/admin-user-profile-update.types'; + +export default function UserPage({ params }: { params: { id: number } }) { + const { toast } = useToast(); + const router = useRouter(); + + const { data: user } = trpc.profile.getUser.useQuery( + { + id: Number(params.id), + }, + { + enabled: !!params.id, + }, + ); + + const roles = user?.roles ?? []; + + const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZUserFormSchema), + values: { + name: user?.name ?? '', + email: user?.email ?? '', + roles: user?.roles ?? [], + }, + }); + + const onSubmit = async ({ name, email, roles }: TUserFormSchema) => { + try { + await updateUserMutation({ + id: Number(user?.id), + name, + email, + roles, + }); + + router.refresh(); + + toast({ + title: 'Profile updated', + description: 'Your profile has been updated.', + duration: 5000, + }); + } catch (e) { + toast({ + title: 'Error', + description: 'An error occurred while updating your profile.', + variant: 'destructive', + }); + } + }; + + return ( +
+

Manage {user?.name}'s profile

+
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + + ( + +
+ Roles + + onChange(values)} + /> + + +
+
+ )} + /> + +
+ +
+
+
+ +
+ ); +} 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..1840f5a44 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/data-table-users.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; + +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'; +import { DataTable } from '@documenso/ui/primitives/data-table'; +import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; +import { Input } from '@documenso/ui/primitives/input'; + +interface User { + id: number; + name: string | null; + email: string; + roles: Role[]; + Subscription: SubscriptionLite[]; + Document: DocumentLite[]; +} + +type SubscriptionLite = Pick< + Subscription, + 'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd' +>; + +type DocumentLite = Pick; + +type UsersDataTableProps = { + users: User[]; + totalPages: number; + perPage: number; + page: number; +}; + +export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => { + const [isPending, startTransition] = useTransition(); + const updateSearchParams = useUpdateSearchParams(); + const [searchString, setSearchString] = useState(''); + const debouncedSearchString = useDebouncedValue(searchString, 1000); + + useEffect(() => { + startTransition(() => { + updateSearchParams({ + search: debouncedSearchString, + page: 1, + perPage, + }); + }); + }, [debouncedSearchString]); + + const onPaginationChange = (page: number, perPage: number) => { + startTransition(() => { + updateSearchParams({ + page, + perPage, + }); + }); + }; + + const handleChange = (e: React.ChangeEvent) => { + setSearchString(e.target.value); + }; + + 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 }) => row.original.roles.join(', '), + }, + { + header: 'Subscription', + accessorKey: 'subscription', + cell: ({ row }) => { + if (row.original.Subscription && row.original.Subscription.length > 0) { + return ( + <> + {row.original.Subscription.map((subscription: SubscriptionLite, i: number) => { + return {subscription.status}; + })} + + ); + } else { + return NONE; + } + }, + }, + { + header: 'Documents', + accessorKey: 'documents', + cell: ({ row }) => { + return
{row.original.Document.length}
; + }, + }, + { + header: '', + 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/fetch-users.actions.ts b/apps/web/src/app/(dashboard)/admin/users/fetch-users.actions.ts new file mode 100644 index 000000000..335f32e08 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/fetch-users.actions.ts @@ -0,0 +1,9 @@ +'use server'; + +import { findUsers } from '@documenso/lib/server-only/user/get-all-users'; + +export async function search(search: string, page: number, perPage: number) { + const results = await findUsers({ username: search, email: search, page, perPage }); + + return results; +} 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..686ce7669 --- /dev/null +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -0,0 +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 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

+ +
+ ); +} 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 0b4a98bab..bec966f9e 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 @@ -65,7 +65,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; @@ -157,6 +157,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) = createAndCopyShareLink({ token: recipient?.token, 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 new file mode 100644 index 000000000..8fd78cae3 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/billing-portal-button.tsx @@ -0,0 +1,55 @@ +'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 const 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..cef36ee3f --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/billing/create-billing-portal.action.ts @@ -0,0 +1,48 @@ +'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'; + +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 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 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 a5c672971..ce41f4f6d 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -1,16 +1,18 @@ -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 { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; 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 { SubscriptionStatus } from '@documenso/prisma/client'; -import { Button } from '@documenso/ui/primitives/button'; import { LocaleDate } from '~/components/formatter/locale-date'; +import { BillingPlans } from './billing-plans'; +import { BillingPortalButton } from './billing-portal-button'; + export default async function BillingSettingsPage() { const { user } = await getRequiredServerComponentSession(); @@ -21,57 +23,75 @@ export default async function BillingSettingsPage() { redirect('/settings/profile'); } - const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => { - if (sub) { - return sub; - } + const [subscription, prices] = await Promise.all([ + getSubscriptionByUserId({ userId: user.id }), + getPricesByInterval(), + ]); - // 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_WEBAPP_URL}/settings/billing`, - }); + subscriptionProduct = foundSubscriptionProduct ?? null; } + const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; + 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. -

- )} + {isMissingOrInactiveOrFreePlan ? : }
); } diff --git a/apps/web/src/pages/api/stripe/webhook/index.ts b/apps/web/src/pages/api/stripe/webhook/index.ts index 9efab2a78..9f1a6937a 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; + + 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/apps/web/src/providers/admin-user-profile-update.types.ts b/apps/web/src/providers/admin-user-profile-update.types.ts new file mode 100644 index 000000000..49bda22fc --- /dev/null +++ b/apps/web/src/providers/admin-user-profile-update.types.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; + +export const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); +export type TUserFormSchema = z.infer; diff --git a/package-lock.json b/package-lock.json index 8fb6594de..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 @@ -2461,6 +2465,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 +3814,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 +5478,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 +7680,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", @@ -9132,6 +9190,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", @@ -10336,6 +10399,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", @@ -10735,6 +10812,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", @@ -12173,6 +12255,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", @@ -12407,6 +12501,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", @@ -12954,6 +13056,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", @@ -15007,6 +15114,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", @@ -15105,6 +15220,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", @@ -15655,6 +15800,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", @@ -17298,6 +17457,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", @@ -17673,6 +17840,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", @@ -17704,6 +17882,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", @@ -17712,6 +18005,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", @@ -18257,8 +18558,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", @@ -19122,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", @@ -19519,6 +19825,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", @@ -19788,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", diff --git a/package.json b/package.json index 57fcb5998..787f7d7f9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev", "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", "prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma", "prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma", "with:env": "dotenv -e .env -e .env.local --" diff --git a/packages/app-tests/.gitignore b/packages/app-tests/.gitignore new file mode 100644 index 000000000..75e854d8d --- /dev/null +++ b/packages/app-tests/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/packages/app-tests/e2e/test-auth-flow.spec.ts b/packages/app-tests/e2e/test-auth-flow.spec.ts new file mode 100644 index 000000000..1221dbf83 --- /dev/null +++ b/packages/app-tests/e2e/test-auth-flow.spec.ts @@ -0,0 +1,55 @@ +import { type Page, expect, test } from '@playwright/test'; + +import { deleteUser } from '@documenso/lib/server-only/user/delete-user'; + +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 = 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'); + 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 deleteUser({ email }); + } catch (e) { + throw new Error(`Error deleting user: ${e}`); + } +}); diff --git a/packages/app-tests/package.json b/packages/app-tests/package.json new file mode 100644 index 000000000..92cfd169d --- /dev/null +++ b/packages/app-tests/package.json @@ -0,0 +1,21 @@ +{ + "name": "@documenso/app-tests", + "version": "1.0.0", + "license": "to-update", + "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": "", + "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/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts new file mode 100644 index 000000000..463b6f97d --- /dev/null +++ b/packages/app-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/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-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/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..f621425cc --- /dev/null +++ b/packages/ee/server-only/stripe/get-prices-by-interval.ts @@ -0,0 +1,49 @@ +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 () => { + 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: [], + 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/admin/get-all-documents.ts b/packages/lib/server-only/admin/get-all-documents.ts new file mode 100644 index 000000000..cca1935a3 --- /dev/null +++ b/packages/lib/server-only/admin/get-all-documents.ts @@ -0,0 +1,55 @@ +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +export interface FindDocumentsOptions { + term?: string; + page?: number; + perPage?: number; +} + +export const findDocuments = async ({ term, page = 1, perPage = 10 }: FindDocumentsOptions) => { + const termFilters: Prisma.DocumentWhereInput | undefined = !term + ? undefined + : { + title: { + contains: term, + mode: 'insensitive', + }, + }; + + const [data, count] = await Promise.all([ + prisma.document.findMany({ + where: { + ...termFilters, + }, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + orderBy: { + createdAt: 'desc', + }, + 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), + }; +}; 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, + }, + }); +}; 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..9013899a7 --- /dev/null +++ b/packages/lib/server-only/admin/update-user.ts @@ -0,0 +1,28 @@ +import { prisma } from '@documenso/prisma'; +import { Role } from '@documenso/prisma/client'; + +export type UpdateUserOptions = { + id: number; + name: string | null | undefined; + email: string | undefined; + roles: Role[] | undefined; +}; + +export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => { + await prisma.user.findFirstOrThrow({ + where: { + id, + }, + }); + + return await prisma.user.update({ + where: { + id, + }, + data: { + name, + email, + roles, + }, + }); +}; 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/server-only/user/delete-user.ts b/packages/lib/server-only/user/delete-user.ts new file mode 100644 index 000000000..df5132aff --- /dev/null +++ b/packages/lib/server-only/user/delete-user.ts @@ -0,0 +1,25 @@ +import { prisma } from '@documenso/prisma'; + +export type DeleteUserOptions = { + email: string; +}; + +export const deleteUser = async ({ email }: DeleteUserOptions) => { + const user = await prisma.user.findFirst({ + where: { + email: { + contains: email, + }, + }, + }); + + if (!user) { + throw new Error(`User with email ${email} not found`); + } + + return await prisma.user.delete({ + where: { + id: user.id, + }, + }); +}; 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..71e670e7d --- /dev/null +++ b/packages/lib/server-only/user/get-all-users.ts @@ -0,0 +1,57 @@ +import { prisma } from '@documenso/prisma'; +import { Prisma } from '@documenso/prisma/client'; + +type GetAllUsersProps = { + username: string; + email: string; + page: number; + perPage: number; +}; + +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({ + include: { + Subscription: true, + Document: { + select: { + id: true, + }, + }, + }, + where: whereClause, + skip: Math.max(page - 1, 0) * perPage, + take: perPage, + }), + await prisma.user.count({ + where: whereClause, + }), + ]); + + return { + users, + totalPages: Math.ceil(count / perPage), + }; +}; 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); +}; 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/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..76c1ab552 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -51,15 +51,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) @@ -219,7 +220,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]) } diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts new file mode 100644 index 000000000..666e3f085 --- /dev/null +++ b/packages/trpc/server/admin-router/router.ts @@ -0,0 +1,23 @@ +import { TRPCError } from '@trpc/server'; + +import { updateUser } from '@documenso/lib/server-only/admin/update-user'; + +import { adminProcedure, router } from '../trpc'; +import { ZUpdateProfileMutationByAdminSchema } from './schema'; + +export const adminRouter = router({ + updateUser: adminProcedure + .input(ZUpdateProfileMutationByAdminSchema) + .mutation(async ({ input }) => { + const { id, name, email, roles } = input; + + try { + return await updateUser({ id, name, email, roles }); + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to retrieve the specified account. Please try again.', + }); + } + }), +}); diff --git a/packages/trpc/server/admin-router/schema.ts b/packages/trpc/server/admin-router/schema.ts new file mode 100644 index 000000000..a20d6f204 --- /dev/null +++ b/packages/trpc/server/admin-router/schema.ts @@ -0,0 +1,13 @@ +import { Role } from '@prisma/client'; +import z from 'zod'; + +export const ZUpdateProfileMutationByAdminSchema = z.object({ + id: z.number().min(1), + name: z.string().nullish(), + email: z.string().email().optional(), + roles: z.array(z.nativeEnum(Role)).optional(), +}); + +export type TUpdateProfileMutationByAdminSchema = z.infer< + typeof ZUpdateProfileMutationByAdminSchema +>; diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 9da1b716e..0f6636650 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -1,19 +1,34 @@ import { TRPCError } from '@trpc/server'; 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, + ZRetrieveUserByIdQuerySchema, ZUpdatePasswordMutationSchema, ZUpdateProfileMutationSchema, } from './schema'; export const profileRouter = router({ + getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => { + 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 32a826ec0..44a8a451c 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(), @@ -19,6 +23,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; diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index b4c65b1d4..519096da9 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -1,3 +1,4 @@ +import { adminRouter } from './admin-router/router'; import { authRouter } from './auth-router/router'; import { documentRouter } from './document-router/router'; import { fieldRouter } from './field-router/router'; @@ -13,6 +14,7 @@ export const appRouter = router({ profile: profileRouter, document: documentRouter, field: fieldRouter, + admin: adminRouter, shareLink: shareLinkRouter, }); 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/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 1db1ec36a..aec4f1d89 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -10,6 +10,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/combobox.tsx b/packages/ui/primitives/combobox.tsx new file mode 100644 index 000000000..899ccd61d --- /dev/null +++ b/packages/ui/primitives/combobox.tsx @@ -0,0 +1,82 @@ +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 }; diff --git a/turbo.json b/turbo.json index 0fc3d8f9c..9628c6bcc 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", @@ -34,6 +33,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_DIRECT_DATABASE_URL", "NEXT_PRIVATE_GOOGLE_CLIENT_ID", @@ -73,6 +73,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" ] -} +} \ No newline at end of file