Compare commits

..

58 Commits

Author SHA1 Message Date
6ba4ff1c17 fix: build errors 2023-10-16 17:38:41 +11:00
652af26754 fix: exports on next page 2023-10-15 20:32:36 +11:00
093488a67c feat: plan limits 2023-10-15 20:26:32 +11:00
0d026f3476 fix: filter out inactive products 2023-10-14 13:02:36 +11:00
3e89ec1afc Merge pull request #384 from documenso/feat/stripe-free-tier
feat: add Stripe free tier subscription
2023-10-14 12:22:31 +11:00
df0d18fc81 Merge pull request #502 from documenso/feat/add-e2e-testing-playwright
feat: add playwright
2023-10-14 12:21:18 +11:00
dd25c355ff Merge pull request #396 from documenso/feat/admin-ui-manage-instance 2023-10-13 18:18:24 +03:00
6f851833b2 Merge branch 'feat/refresh' into feat/admin-ui-manage-instance 2023-10-13 16:44:44 +02:00
442b089d7f fix: style updates 2023-10-14 00:20:11 +11:00
1c58b21383 Merge branch 'feat/refresh' into feat/add-e2e-testing-playwright 2023-10-14 00:13:41 +11:00
a6e13faf7b fix: quick tweaks 2023-10-13 13:08:39 +00:00
fab006078c Merge pull request #554 from documenso/fix/cascade-delete-share-links
fix: add cascade delete for share links
2023-10-13 23:41:13 +11:00
pit
901e83af58 chore: implemented feedback 2023-10-13 12:16:07 +03:00
pit
e1bee1591f chore: implemented feedback 2023-10-13 11:48:52 +03:00
a354c23231 feat: add document share button to marketing (#422) 2023-10-13 14:14:13 +11:00
f728dd13c5 fix: add cascade delete for share links 2023-10-13 12:45:39 +11:00
pit
7927b87259 chore: polished code 2023-10-12 17:07:54 +03:00
pit
55301a9d53 chore: revert back env file name 2023-10-12 12:49:39 +03:00
pit
c0dd57a4d2 chore: implement feedback 2023-10-12 12:19:23 +03:00
pit
cc80773402 chore: implement feedback 2023-10-12 11:44:16 +03:00
c803d2c4ba feat: single-player-mode-polish (#435) 2023-10-12 18:10:52 +11:00
eb5f5f7a90 fix: background color of signature page (#487) 2023-10-12 14:08:26 +11:00
2ea5ff2c94 fix: bypass signature fix (#536) (#547) 2023-10-12 11:33:01 +11:00
pit
bc9a6fa50a chore: implemented feedback 2023-10-11 16:20:04 +03:00
pit
e02ab7d256 chore: implement pr feedback 2023-10-11 12:32:33 +03:00
pit
9e0d281883 chore: feedback fix 2023-10-10 16:52:58 +03:00
pit
67629dd735 chore: fix eslint issues 2023-10-10 13:57:07 +03:00
pit
2a89278c7b chore: merge feat/refresh 2023-10-10 13:53:22 +03:00
pit
8f4ba6eb8a chore: self-review 2023-10-10 13:50:50 +03:00
8dfcfb99e0 Merge branch 'feat/refresh' into feat/add-e2e-testing-playwright 2023-10-10 11:49:00 +03:00
pit
1299aa51ee chore: move fetching in data-table-users 2023-10-10 11:44:16 +03:00
pit
a11440a7f3 chore: tidy up 2023-10-09 13:30:28 +03:00
pit
4c518df60d chore: remove generic data table 2023-10-09 12:02:55 +03:00
pit
d4ae733e9e chore: add transition and check for empty users array 2023-10-09 11:59:08 +03:00
pit
5f14f87406 feat: filter users by name or email 2023-10-06 15:48:05 +03:00
pit
2b44e54d99 feat: subscriptions and documents page 2023-10-05 18:35:12 +03:00
pit
aecc703317 chore: remove this branch 2023-10-05 12:11:17 +03:00
pit
2422c3e7be chore: update e2e tests 2023-10-05 11:56:32 +03:00
pit
4e1994a0c8 chore: update import 2023-10-05 11:46:45 +03:00
pit
a3dce67117 chore: changes 2023-10-05 11:38:51 +03:00
pit
64dcd451e9 chore: add schema location 2023-10-05 10:56:09 +03:00
pit
a85523ecfc chore: change from npm to npx 2023-10-05 10:53:19 +03:00
pit
85b32bb15b chore: install prisma before prisma client 2023-10-05 10:24:03 +03:00
pit
742ad86b10 chore: add remote caching 2023-10-05 10:13:43 +03:00
pit
39ff11a59d chore: use env vars for tests 2023-10-05 09:12:56 +03:00
pit
4f5976479a chore: merge feat/refresh 2023-10-05 08:47:03 +03:00
pit
d10713b477 ci: trigger ci 2023-10-03 10:21:48 +01:00
pit
2efaabd2c3 ci: trigger ci 2023-10-03 10:20:39 +01:00
pit
7bc1e9dcc8 chore: add env step in gh action 2023-10-03 10:19:54 +01:00
pit
8848df701c chore: added delete function 2023-10-03 10:09:40 +01:00
pit
2e800d0eed chore: removed lint step 2023-10-03 10:01:44 +01:00
pit
70ecc9a4a8 feat: add playwright 2023-10-03 09:53:47 +01:00
pit
b5fc6e1aaf feat: manage documents admin ui 2023-10-02 16:55:04 +01:00
pit
87f70fa290 feat: profile page done 2023-10-02 11:38:04 +01:00
pit
c2cda0f06e feat: update user functionality 2023-09-29 17:26:37 +01:00
pit
f1bc772985 chore: improve the ui 2023-09-29 17:12:02 +01:00
pit
07bf780c3e feat: build individual user page 2023-09-21 15:10:20 +01:00
pit
775de16d0a feat: admin ui for managing instance 2023-09-21 12:43:36 +01:00
85 changed files with 2319 additions and 385 deletions

View File

@ -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. # 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" 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]] # [[STORAGE]]
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3 # OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
NEXT_PUBLIC_UPLOAD_TRANSPORT="database" NEXT_PUBLIC_UPLOAD_TRANSPORT="database"

51
.github/workflows/e2e-tests.yml vendored Normal file
View File

@ -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 }}

View File

@ -2,6 +2,8 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer'; import { Footer } from '~/components/(marketing)/footer';
@ -13,6 +15,7 @@ export type MarketingLayoutProps = {
export default function MarketingLayout({ children }: MarketingLayoutProps) { export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0); const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
useEffect(() => { useEffect(() => {
const onScroll = () => { const onScroll = () => {
@ -25,7 +28,11 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
}, []); }, []);
return ( return (
<div className="relative max-w-[100vw] overflow-y-auto overflow-x-hidden pt-20 md:pt-28"> <div
className={cn('relative max-w-[100vw] pt-20 md:pt-28', {
'overflow-y-auto overflow-x-hidden': pathname !== '/singleplayer',
})}
>
<div <div
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', { className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5, 'bg-background/50 backdrop-blur-md': scrollY > 5,

View File

@ -130,7 +130,7 @@ export default function SinglePlayerModePage() {
signer: data.email, signer: data.email,
}); });
router.push(`/single-player-mode/${documentToken}/success`); router.push(`/singleplayer/${documentToken}/success`);
} catch { } catch {
toast({ toast({
title: 'Something went wrong', title: 'Something went wrong',

View File

@ -4,6 +4,7 @@ import { Caveat, Inter } from 'next/font/google';
import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag'; import { FeatureFlagProvider } from '@documenso/lib/client-only/providers/feature-flag';
import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag'; import { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster'; import { Toaster } from '@documenso/ui/primitives/toaster';
@ -63,7 +64,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body> <body>
<FeatureFlagProvider initialFlags={flags}> <FeatureFlagProvider initialFlags={flags}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<PlausibleProvider>{children}</PlausibleProvider> <PlausibleProvider>
<TrpcProvider>{children}</TrpcProvider>
</PlausibleProvider>
</ThemeProvider> </ThemeProvider>
</FeatureFlagProvider> </FeatureFlagProvider>

View File

@ -23,7 +23,7 @@ const SOCIAL_LINKS = [
const FOOTER_LINKS = [ const FOOTER_LINKS = [
{ href: '/pricing', text: 'Pricing' }, { href: '/pricing', text: 'Pricing' },
{ href: '/single-player-mode', text: 'Single Player Mode' }, { href: '/singleplayer', text: 'Singleplayer' },
{ href: '/blog', text: 'Blog' }, { href: '/blog', text: 'Blog' },
{ href: '/open', text: 'Open' }, { href: '/open', text: 'Open' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' }, { href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },

View File

@ -35,7 +35,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
{isSinglePlayerModeMarketingEnabled && ( {isSinglePlayerModeMarketingEnabled && (
<Link <Link
href="/single-player-mode" href="/singleplayer"
className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3" className="bg-primary dark:text-background rounded-full px-2 py-1 text-xs font-semibold sm:px-3"
> >
Try now! Try now!

View File

@ -134,9 +134,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
variants={HeroTitleVariants} variants={HeroTitleVariants}
initial="initial" initial="initial"
animate="animate" animate="animate"
className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition duration-300" className="border-primary bg-background hover:bg-muted mx-auto mt-8 w-60 rounded-xl border transition-colors duration-300"
> >
<Link href="/single-player-mode" className="block px-4 py-2 text-center"> <Link href="/singleplayer" className="block px-4 py-2 text-center">
<h2 className="text-muted-foreground text-xs font-semibold"> <h2 className="text-muted-foreground text-xs font-semibold">
Introducing Single Player Mode Introducing Single Player Mode
</h2> </h2>

View File

@ -17,8 +17,8 @@ export type MobileNavigationProps = {
export const MENU_NAVIGATION_LINKS = [ export const MENU_NAVIGATION_LINKS = [
{ {
href: '/single-player-mode', href: '/singleplayer',
text: 'Single Player Mode', text: 'Singleplayer',
}, },
{ {
href: '/blog', href: '/blog',

View File

@ -1,9 +1,8 @@
'use client'; 'use client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type PasswordRevealProps = { export type PasswordRevealProps = {
password: string; password: string;
}; };

View File

@ -4,14 +4,13 @@ import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Share } from 'lucide-react';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { base64 } from '@documenso/lib/universal/base64'; import { base64 } from '@documenso/lib/universal/base64';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -87,11 +86,11 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
<div className="relative mt-8 w-full"> <div className="relative mt-8 w-full">
<div className={cn('flex flex-col items-center', className)}> <div className={cn('flex flex-col items-center', className)}>
<div className="grid w-full max-w-sm grid-cols-2 gap-4"> <div className="grid w-full max-w-sm grid-cols-2 gap-4">
{/* TODO: Hook this up */} <DocumentShareButton
<Button variant="outline" className="flex-1 bg-transparent backdrop-blur-sm" disabled> documentId={document.id}
<Share className="mr-2 h-5 w-5" /> token={document.Recipient.token}
Share className="flex-1 bg-transparent backdrop-blur-sm"
</Button> />
<DocumentDownloadButton <DocumentDownloadButton
className="flex-1 bg-transparent backdrop-blur-sm" className="flex-1 bg-transparent backdrop-blur-sm"
@ -103,7 +102,7 @@ export const SinglePlayerModeSuccess = ({ className, document }: SinglePlayerMod
<Button <Button
onClick={async () => onShowDocumentClick()} onClick={async () => onShowDocumentClick()}
loading={isFetchingDocumentFile} loading={isFetchingDocumentFile}
className="col-span-2" className="z-10 col-span-2"
> >
Show document Show document
</Button> </Button>

View File

@ -41,7 +41,7 @@ export default async function handler(
}, },
}); });
if (user && user.Subscription.length > 0) { if (user && user.Subscription) {
return res.status(200).json({ return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`, redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
}); });

View File

@ -0,0 +1,8 @@
import * as trpcNext from '@documenso/trpc/server/adapters/next';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }),
});

View File

@ -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<User, 'id' | 'name' | 'email'>;
}
>;
};
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
accessorKey: 'title',
cell: ({ row }) => {
return <div>{row.original.title}</div>;
},
},
{
header: 'Owner',
accessorKey: 'owner',
cell: ({ row }) => {
return (
<Link href={`/admin/users/${row.original.User.id}`}>
<Avatar className="dark:border-border h-12 w-12 border-2 border-solid border-white">
<AvatarFallback className="text-gray-400">
<span className="text-xs">{row.original.User.name}</span>
</AvatarFallback>
</Avatar>
</Link>
);
},
},
{
header: 'Last updated',
accessorKey: 'updatedAt',
cell: ({ row }) => <LocaleDate date={row.original.updatedAt} />,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
},
]}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@ -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 (
<div>
<h2 className="text-4xl font-semibold">Manage documents</h2>
<div className="mt-8">
<DocumentsDataTable results={results} />
</div>
</div>
);
}

View File

@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; 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 { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -37,10 +37,40 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
'justify-start md:w-full', 'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary', pathname?.startsWith('/admin/users') && 'bg-secondary',
)} )}
disabled asChild
> >
<User2 className="mr-2 h-5 w-5" /> <Link href="/admin/users">
Users (Coming Soon) <User2 className="mr-2 h-5 w-5" />
Users
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/documents') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/documents">
<FileStack className="mr-2 h-5 w-5" />
Documents
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/subscriptions') && 'bg-secondary',
)}
asChild
>
<Link href="/admin/subscriptions">
<Wallet2 className="mr-2 h-5 w-5" />
Subscriptions
</Link>
</Button> </Button>
</div> </div>
); );

View File

@ -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 (
<div>
<h2 className="text-4xl font-semibold">Manage subscriptions</h2>
<div className="mt-8">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Ends On</TableHead>
<TableHead>User ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subscriptions.map((subscription, index) => (
<TableRow key={index}>
<TableCell>{subscription.id}</TableCell>
<TableCell>{subscription.status}</TableCell>
<TableCell>
{subscription.createdAt
? new Date(subscription.createdAt).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'N/A'}
</TableCell>
<TableCell>
{subscription.periodEnd
? new Date(subscription.periodEnd).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'N/A'}
</TableCell>
<TableCell>
<Link href={`/admin/users/${subscription.userId}`}>{subscription.userId}</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,141 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
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';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
type TUserFormSchema = z.infer<typeof ZUserFormSchema>;
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<TUserFormSchema>({
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 (
<div>
<h2 className="text-4xl font-semibold">Manage {user?.name}'s profile</h2>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset className="mt-6 flex w-full flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Name</FormLabel>
<FormControl>
<Input type="text" {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">Email</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roles"
render={({ field: { onChange } }) => (
<FormItem>
<fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl>
<Combobox
listValues={roles}
onChange={(values: string[]) => onChange(values)}
/>
</FormControl>
<FormMessage />
</fieldset>
</FormItem>
)}
/>
<div className="mt-4">
<Button type="submit" loading={form.formState.isSubmitting}>
Update user
</Button>
</div>
</fieldset>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,143 @@
'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 | null;
Document: DocumentLite[];
}
type SubscriptionLite = Pick<
Subscription,
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
>;
type DocumentLite = Pick<Document, 'id'>;
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<HTMLInputElement>) => {
setSearchString(e.target.value);
};
return (
<div className="relative">
<Input
className="my-6 flex flex-row gap-4"
type="text"
placeholder="Search by name or email"
value={searchString}
onChange={handleChange}
/>
<DataTable
columns={[
{
header: 'ID',
accessorKey: 'id',
cell: ({ row }) => <div>{row.original.id}</div>,
},
{
header: 'Name',
accessorKey: 'name',
cell: ({ row }) => <div>{row.original.name}</div>,
},
{
header: 'Email',
accessorKey: 'email',
cell: ({ row }) => <div>{row.original.email}</div>,
},
{
header: 'Roles',
accessorKey: 'roles',
cell: ({ row }) => row.original.roles.join(', '),
},
{
header: 'Subscription',
accessorKey: 'subscription',
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE',
},
{
header: 'Documents',
accessorKey: 'documents',
cell: ({ row }) => {
return <div>{row.original.Document.length}</div>;
},
},
{
header: '',
accessorKey: 'edit',
cell: ({ row }) => {
return (
<Button className="w-24" asChild>
<Link href={`/admin/users/${row.original.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
);
},
},
]}
data={users}
perPage={perPage}
currentPage={page}
totalPages={totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@ -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;
}

View File

@ -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 (
<div>
<h2 className="text-4xl font-semibold">Manage users</h2>
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} />
</div>
);
}

View File

@ -6,13 +6,12 @@ import { Edit, Pencil, Share } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client'; import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type DataTableActionButtonProps = { export type DataTableActionButtonProps = {
row: Document & { row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>; User: Pick<User, 'id' | 'name' | 'email'>;
@ -47,7 +46,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
documentId: row.id, documentId: row.id,
}); });
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null); await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',

View File

@ -18,6 +18,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client'; import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
@ -32,8 +33,6 @@ import {
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog'; import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
export type DataTableActionDropdownProps = { export type DataTableActionDropdownProps = {
@ -61,7 +60,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isOwner = row.User.id === session.user.id; const isOwner = row.User.id === session.user.id;
// const isRecipient = !!recipient; // const isRecipient = !!recipient;
// const isDraft = row.status === DocumentStatus.DRAFT; const isDraft = row.status === DocumentStatus.DRAFT;
// const isPending = row.status === DocumentStatus.PENDING; // const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; // const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
@ -73,7 +72,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
documentId: row.id, documentId: row.id,
}); });
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null); await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
@ -166,7 +165,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Resend Resend
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={onShareClick}> <DropdownMenuItem disabled={isDraft} onClick={onShareClick}>
{isCreatingShareLink ? ( {isCreatingShareLink ? (
<Loader className="mr-2 h-4 w-4" /> <Loader className="mr-2 h-4 w-4" />
) : ( ) : (

View File

@ -2,12 +2,15 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data'; import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFile } from '@documenso/lib/universal/upload/put-file'; import { putFile } from '@documenso/lib/universal/upload/put-file';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone'; import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
@ -22,6 +25,8 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { quota, remaining } = useLimits();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
@ -52,11 +57,19 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
toast({ if (error instanceof TRPCClientError) {
title: 'Error', toast({
description: 'An error occurred while uploading your document.', title: 'Error',
variant: 'destructive', description: error.message,
}); variant: 'destructive',
});
} else {
toast({
title: 'Error',
description: 'An error occurred while uploading your document.',
variant: 'destructive',
});
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -64,13 +77,46 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
<DocumentDropzone className="min-h-[40vh]" onDrop={onFileDrop} /> <DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0}
onDrop={onFileDrop}
/>
<div className="absolute -bottom-6 right-0">
{remaining.documents > 0 && Number.isFinite(remaining.documents) && (
<p className="text-muted-foreground/60 text-xs">
{remaining.documents} of {quota.documents} documents remaining this month.
</p>
)}
</div>
{isLoading && ( {isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center"> <div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" /> <Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div> </div>
)} )}
{remaining.documents === 0 && (
<div className="bg-background/60 absolute inset-0 flex items-center justify-center rounded-lg backdrop-blur-sm">
<div className="text-center">
<h2 className="text-muted-foreground/80 text-xl font-semibold">
You have reached your document limit.
</h2>
<p className="text-muted-foreground/60 mt-2 text-sm">
You can upload up to {quota.documents} documents per month on your current plan.
</p>
<Link
className="text-primary hover:text-primary/80 mt-6 block font-medium"
href="/settings/billing"
>
Upgrade your account to upload more documents.
</Link>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options'; import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
@ -28,11 +29,13 @@ export default async function AuthenticatedDashboardLayout({
return ( return (
<NextAuthProvider session={session}> <NextAuthProvider session={session}>
<Header user={user} /> <LimitsProvider>
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main> <main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
<RefreshOnFocus /> <RefreshOnFocus />
</LimitsProvider>
</NextAuthProvider> </NextAuthProvider>
); );
} }

View File

@ -3,9 +3,10 @@ import { redirect } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; 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 { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id'; import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { LocaleDate } from '~/components/formatter/locale-date'; import { LocaleDate } from '~/components/formatter/locale-date';
@ -30,12 +31,10 @@ export default async function BillingSettingsPage() {
let subscriptionProduct: Stripe.Product | null = null; let subscriptionProduct: Stripe.Product | null = null;
if (subscription?.planId) { if (subscription?.priceId) {
const foundSubscriptionProduct = (await stripe.products.list()).data.find( subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
(item) => item.default_price === subscription.planId, () => null,
); );
subscriptionProduct = foundSubscriptionProduct ?? null;
} }
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';

View File

@ -9,12 +9,11 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { DocumentStatus, FieldType } from '@documenso/prisma/client'; import { DocumentStatus, FieldType } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import signingCelebration from '~/assets/signing-celebration.png'; import signingCelebration from '~/assets/signing-celebration.png';
import { ShareButton } from './share-button';
export type CompletedSigningPageProps = { export type CompletedSigningPageProps = {
params: { params: {
token?: string; token?: string;
@ -89,7 +88,7 @@ export default async function CompletedSigningPage({
))} ))}
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4"> <div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<ShareButton documentId={document.id} token={recipient.token} /> <DocumentShareButton documentId={document.id} token={recipient.token} />
<DocumentDownloadButton <DocumentDownloadButton
className="flex-1" className="flex-1"

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useMemo, useState, useTransition } from 'react'; import { useEffect, useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -48,6 +48,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
const [showSignatureModal, setShowSignatureModal] = useState(false); const [showSignatureModal, setShowSignatureModal] = useState(false);
const [localSignature, setLocalSignature] = useState<string | null>(null); const [localSignature, setLocalSignature] = useState<string | null>(null);
const [isLocalSignatureSet, setIsLocalSignatureSet] = useState(false);
const state = useMemo<SignatureFieldState>(() => { const state = useMemo<SignatureFieldState>(() => {
if (!field.inserted) { if (!field.inserted) {
@ -61,9 +62,16 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
return 'signed-text'; return 'signed-text';
}, [field.inserted, signature?.signatureImageAsBase64]); }, [field.inserted, signature?.signatureImageAsBase64]);
useEffect(() => {
if (!showSignatureModal && !isLocalSignatureSet) {
setLocalSignature(null);
}
}, [showSignatureModal, isLocalSignatureSet]);
const onSign = async (source: 'local' | 'provider' = 'provider') => { const onSign = async (source: 'local' | 'provider' = 'provider') => {
try { try {
if (!providedSignature && !localSignature) { if (!providedSignature && !localSignature) {
setIsLocalSignatureSet(false);
setShowSignatureModal(true); setShowSignatureModal(true);
return; return;
} }
@ -178,6 +186,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
disabled={!localSignature} disabled={!localSignature}
onClick={() => { onClick={() => {
setShowSignatureModal(false); setShowSignatureModal(false);
setIsLocalSignatureSet(true);
void onSign('local'); void onSign('local');
}} }}
> >

View File

@ -117,7 +117,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
name="signature" name="signature"
render={({ field: { onChange } }) => ( render={({ field: { onChange } }) => (
<SignaturePad <SignaturePad
className="h-44 w-full rounded-lg border bg-white backdrop-blur-sm dark:border-[#e2d7c5] dark:bg-[#fcf8ee]" className="h-44 w-full"
containerClassName="rounded-lg border bg-background"
defaultValue={user.signature ?? undefined} defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')} onChange={(v) => onChange(v ?? '')}
/> />

View File

@ -147,7 +147,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
name="signature" name="signature"
render={({ field: { onChange } }) => ( render={({ field: { onChange } }) => (
<SignaturePad <SignaturePad
className="mt-2 h-36 w-full rounded-lg border bg-white dark:border-[#e2d7c5] dark:bg-[#fcf8ee]" className="h-36 w-full"
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')} onChange={(v) => onChange(v ?? '')}
/> />
)} )}

View File

@ -1,28 +0,0 @@
import { useState } from 'react';
export type CopiedValue = string | null;
export type CopyFn = (_text: string) => Promise<boolean>;
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
const copy: CopyFn = async (text) => {
if (!navigator?.clipboard) {
console.warn('Clipboard not supported');
return false;
}
// Try to save to clipboard then save it in the state if worked
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
return true;
} catch (error) {
console.warn('Copy failed', error);
setCopiedText(null);
return false;
}
};
return [copiedText, copy];
}

View File

@ -41,7 +41,7 @@ export default async function handler(
}, },
}); });
if (user && user.Subscription.length > 0) { if (user && user.Subscription) {
return res.status(200).json({ return res.status(200).json({
redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`, redirectUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/login`,
}); });

View File

@ -0,0 +1,3 @@
import { limitsHandler } from '@documenso/ee/server-only/limits/handler';
export default limitsHandler;

View File

@ -1,237 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next'; import { stripeWebhookHandler } from '@documenso/ee/server-only/stripe/webhook/handler';
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';
import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
ReadStatus,
SendStatus,
SigningStatus,
SubscriptionStatus,
} from '@documenso/prisma/client';
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
export const config = { export const config = {
api: { bodyParser: false }, api: { bodyParser: false },
}; };
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default stripeWebhookHandler;
// if (!process.env.NEXT_PUBLIC_ALLOW_SUBSCRIPTIONS) {
// return res.status(500).json({
// success: false,
// message: 'Subscriptions are not enabled',
// });
// }
const sig =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!sig) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
log('constructing body...');
const body = await buffer(req);
log('constructed body');
const event = stripe.webhooks.constructEvent(
body,
sig,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, turbo/no-undeclared-env-vars
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET!,
);
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
const session = event.data.object as Stripe.Checkout.Session;
if (session.metadata?.source === 'landing') {
const user = await prisma.user.findFirst({
where: {
id: Number(session.client_reference_id),
},
});
if (!user) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
const signatureText = session.metadata?.signatureText || user.name;
let signatureDataUrl = '';
if (session.metadata?.signatureDataUrl) {
const result = await redis.get<string>(`signature:${session.metadata.signatureDataUrl}`);
if (result) {
signatureDataUrl = result;
}
}
const now = new Date();
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const { id: documentDataId } = await prisma.documentData.create({
data: {
type: DocumentDataType.BYTES_64,
data: bytes64,
initialData: bytes64,
},
});
const document = await prisma.document.create({
data: {
title: 'Documenso Supporter Pledge.pdf',
status: DocumentStatus.COMPLETED,
userId: user.id,
documentDataId,
},
include: {
documentData: true,
},
});
const { documentData } = document;
if (!documentData) {
throw new Error(`Document ${document.id} has no document data`);
}
const recipient = await prisma.recipient.create({
data: {
name: user.name ?? '',
email: user.email,
token: randomBytes(16).toString('hex'),
signedAt: now,
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.SIGNED,
documentId: document.id,
},
});
const field = await prisma.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
type: FieldType.SIGNATURE,
page: 0,
positionX: 77,
positionY: 638,
inserted: false,
customText: '',
},
});
if (signatureDataUrl) {
documentData.data = await insertImageInPDF(
documentData.data,
signatureDataUrl,
field.positionX.toNumber(),
field.positionY.toNumber(),
field.page,
);
} else {
documentData.data = await insertTextInPDF(
documentData.data,
signatureText ?? '',
field.positionX.toNumber(),
field.positionY.toNumber(),
field.page,
);
}
await Promise.all([
prisma.signature.create({
data: {
fieldId: field.id,
recipientId: recipient.id,
signatureImageAsBase64: signatureDataUrl || undefined,
typedSignature: signatureDataUrl ? '' : signatureText,
},
}),
prisma.document.update({
where: {
id: document.id,
},
data: {
documentData: {
update: {
data: documentData.data,
},
},
},
}),
]);
}
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
log('Unhandled webhook event', event.type);
return res.status(400).json({
success: false,
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(),
},
});
};

View File

@ -6,7 +6,3 @@ export default trpcNext.createNextApiHandler({
router: appRouter, router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }), createContext: async ({ req, res }) => createTrpcContext({ req, res }),
}); });
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
// res.json({ hello: 'world' });
// }

367
package-lock.json generated
View File

@ -1848,6 +1848,10 @@
"@jridgewell/sourcemap-codec": "^1.4.10" "@jridgewell/sourcemap-codec": "^1.4.10"
} }
}, },
"node_modules/@documenso/app-tests": {
"resolved": "packages/app-tests",
"link": true
},
"node_modules/@documenso/ee": { "node_modules/@documenso/ee": {
"resolved": "packages/ee", "resolved": "packages/ee",
"link": true "link": true
@ -2461,6 +2465,19 @@
"node": ">=6" "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": { "node_modules/@hookform/resolvers": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.0.tgz",
@ -3797,6 +3814,21 @@
"url": "https://opencollective.com/unts" "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": { "node_modules/@prisma/client": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.3.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.3.1.tgz",
@ -5446,6 +5478,24 @@
"url": "https://ko-fi.com/killymxi" "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": { "node_modules/@sindresorhus/slugify": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz",
@ -7630,6 +7680,14 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/chokidar": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@ -9132,6 +9190,11 @@
"node": ">=12" "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": { "node_modules/duplexer2": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@ -10336,6 +10399,20 @@
"node": ">=0.10.0" "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": { "node_modules/eventemitter3": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "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": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
"integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==" "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": { "node_modules/jose": {
"version": "4.14.4", "version": "4.14.4",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz",
@ -12407,6 +12501,14 @@
"language-subtag-registry": "~0.3.2" "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": { "node_modules/leac": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
@ -12954,6 +13056,11 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/markdown-extensions": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz",
@ -15007,6 +15114,14 @@
"node": ">=8" "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": { "node_modules/pdf-lib": {
"version": "1.17.1", "version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
@ -15105,6 +15220,36 @@
"node": ">= 6" "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": { "node_modules/postcss": {
"version": "8.4.27", "version": "8.4.27",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.27.tgz", "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", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz",
"integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" "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": { "node_modules/pump": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/rusha/-/rusha-0.8.14.tgz",
"integrity": "sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==" "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": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "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", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz",
"integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==" "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": { "node_modules/split2": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", "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", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" "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": { "node_modules/statuses": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
@ -17712,6 +18005,14 @@
"node": ">= 0.6" "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": { "node_modules/stream-shift": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz",
@ -18257,8 +18558,7 @@
"node_modules/through": { "node_modules/through": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
"dev": true
}, },
"node_modules/through2": { "node_modules/through2": {
"version": "4.0.2", "version": "4.0.2",
@ -19122,6 +19422,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/unified": {
"version": "10.1.2", "version": "10.1.2",
"resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz",
@ -19519,6 +19825,24 @@
"d3-timer": "^3.0.1" "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": { "node_modules/watchpack": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
@ -19788,15 +20112,49 @@
"url": "https://github.com/sponsors/wooorm" "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": { "packages/ee": {
"name": "@documenso/ee", "name": "@documenso/ee",
"version": "1.0.0", "version": "1.0.0",
"license": "COMMERCIAL", "license": "COMMERCIAL",
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*" "@documenso/prisma": "*",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.4.19",
"next-auth": "4.22.3",
"react": "18.2.0",
"ts-pattern": "^5.0.5",
"zod": "^3.21.4"
} }
}, },
"packages/ee/node_modules/ts-pattern": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz",
"integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA=="
},
"packages/email": { "packages/email": {
"name": "@documenso/email", "name": "@documenso/email",
"version": "1.0.0", "version": "1.0.0",
@ -19856,7 +20214,8 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"react": "18.2.0", "react": "18.2.0",
"stripe": "^12.7.0", "stripe": "^12.7.0",
"ts-pattern": "^5.0.5" "ts-pattern": "^5.0.5",
"zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",

View File

@ -14,6 +14,7 @@
"dx": "npm i && npm run dx:up && npm run prisma:migrate-dev", "dx": "npm i && npm run dx:up && npm run prisma:migrate-dev",
"dx:up": "docker compose -f docker/compose-services.yml up -d", "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",
"prisma:migrate-dev": "npm run with:env -- npm run prisma:migrate-dev -w @documenso/prisma", "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", "prisma:migrate-deploy": "npm run with:env -- npm run prisma:migrate-deploy -w @documenso/prisma",
"with:env": "dotenv -e .env -e .env.local --" "with:env": "dotenv -e .env -e .env.local --"

4
packages/app-tests/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/

View File

@ -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}`);
}
});

View File

@ -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"
}
}

View File

@ -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,
// },
});

View File

@ -14,6 +14,13 @@
}, },
"dependencies": { "dependencies": {
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*" "@documenso/prisma": "*",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"next": "13.4.19",
"next-auth": "4.22.3",
"react": "18.2.0",
"ts-pattern": "^5.0.5",
"zod": "^3.21.4"
} }
} }

View File

@ -0,0 +1,31 @@
import { APP_BASE_URL } from '@documenso/lib/constants/app';
import { FREE_PLAN_LIMITS } from './constants';
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
export type GetLimitsOptions = {
headers?: Record<string, string>;
};
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
const requestHeaders = headers ?? {};
const url = new URL(`${APP_BASE_URL}/api/limits`);
return fetch(url, {
headers: {
...requestHeaders,
},
next: {
revalidate: 60,
},
})
.then(async (res) => res.json())
.then((res) => ZLimitsResponseSchema.parse(res))
.catch(() => {
return {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
} satisfies TLimitsResponseSchema;
});
};

View File

@ -0,0 +1,11 @@
import { TLimitsSchema } from './schema';
export const FREE_PLAN_LIMITS: TLimitsSchema = {
documents: 5,
recipients: 10,
};
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
documents: Infinity,
recipients: Infinity,
};

View File

@ -0,0 +1,6 @@
export const ERROR_CODES: Record<string, string> = {
UNAUTHORIZED: 'You must be logged in to access this resource',
USER_FETCH_FAILED: 'An error occurred while fetching your user account',
SUBSCRIPTION_FETCH_FAILED: 'An error occurred while fetching your subscription',
UNKNOWN: 'An unknown error occurred',
};

View File

@ -0,0 +1,54 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { getToken } from 'next-auth/jwt';
import { match } from 'ts-pattern';
import { withStaleWhileRevalidate } from '@documenso/lib/server-only/http/with-swr';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
import { getServerLimits } from './server';
export const limitsHandler = async (
req: NextApiRequest,
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
) => {
try {
const token = await getToken({ req });
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return withStaleWhileRevalidate<typeof res>(res).status(200).json({
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
});
}
if (!token?.email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
const limits = await getServerLimits({ email: token.email });
return withStaleWhileRevalidate<typeof res>(res).status(200).json(limits);
} catch (err) {
console.error('error', err);
if (err instanceof Error) {
const status = match(err.message)
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
.otherwise(() => 500);
return res.status(status).json({
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
});
}
res.status(500).json({
error: ERROR_CODES.UNKNOWN,
});
}
};

View File

@ -0,0 +1,53 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import { getLimits } from '../client';
import { FREE_PLAN_LIMITS } from '../constants';
import { TLimitsResponseSchema } from '../schema';
export type LimitsContextValue = TLimitsResponseSchema;
const LimitsContext = createContext<LimitsContextValue | null>(null);
export const useLimits = () => {
const limits = useContext(LimitsContext);
if (!limits) {
throw new Error('useLimits must be used within a LimitsProvider');
}
return limits;
};
export type LimitsProviderProps = {
initialValue?: LimitsContextValue;
children?: React.ReactNode;
};
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
const defaultValue: TLimitsResponseSchema = {
quota: FREE_PLAN_LIMITS,
remaining: FREE_PLAN_LIMITS,
};
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
useEffect(() => {
void getLimits().then((limits) => setLimits(limits));
}, []);
useEffect(() => {
const onFocus = () => {
void getLimits().then((limits) => setLimits(limits));
};
window.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('focus', onFocus);
};
}, []);
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
};

View File

@ -0,0 +1,18 @@
'use server';
import { headers } from 'next/headers';
import { getLimits } from '../client';
import { LimitsProvider as ClientLimitsProvider } from './client';
export type LimitsProviderProps = {
children?: React.ReactNode;
};
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
const requestHeaders = Object.fromEntries(headers().entries());
const limits = await getLimits({ headers: requestHeaders });
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
};

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
export const ZLimitsSchema = z.object({
documents: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
recipients: z
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
.optional()
.default(0),
});
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
export const ZLimitsResponseSchema = z.object({
quota: ZLimitsSchema,
remaining: ZLimitsSchema,
});
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
export const ZLimitsErrorResponseSchema = z.object({
error: z.string(),
});
export type TLimitsErrorResponseSchema = z.infer<typeof ZLimitsErrorResponseSchema>;

View File

@ -0,0 +1,78 @@
import { DateTime } from 'luxon';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema';
export type GetServerLimitsOptions = {
email?: string;
};
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return {
quota: SELFHOSTED_PLAN_LIMITS,
remaining: SELFHOSTED_PLAN_LIMITS,
};
}
if (!email) {
throw new Error(ERROR_CODES.UNAUTHORIZED);
}
const user = await prisma.user.findFirst({
where: {
email,
},
include: {
Subscription: true,
},
});
if (!user) {
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
}
let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS);
if (user.Subscription?.priceId) {
const { product } = await stripe.prices
.retrieve(user.Subscription.priceId, {
expand: ['product'],
})
.catch((err) => {
console.error(err);
throw err;
});
if (typeof product === 'string') {
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
}
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
remaining = structuredClone(quota);
}
const documents = await prisma.document.count({
where: {
userId: user.id,
createdAt: {
gte: DateTime.utc().startOf('month').toJSDate(),
},
},
});
remaining.documents = Math.max(remaining.documents - documents, 0);
return {
quota,
remaining,
};
};

View File

@ -8,12 +8,21 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>; export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export const getPricesByInterval = async () => { export const getPricesByInterval = async () => {
const { data: prices } = await stripe.prices.search({ let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`, query: `active:'true' type:'recurring'`,
expand: ['data.product'], expand: ['data.product'],
limit: 100, 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 = { const intervals: PriceIntervals = {
day: [], day: [],
week: [], week: [],

View File

@ -0,0 +1,17 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetProductByPriceIdOptions = {
priceId: string;
};
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
const { product } = await stripe.prices.retrieve(priceId, {
expand: ['product'],
});
if (typeof product === 'string' || 'deleted' in product) {
throw new Error('Product not found');
}
return product;
};

View File

@ -0,0 +1,224 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma';
import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated';
type StripeWebhookResponse = {
success: boolean;
message: string;
};
export const stripeWebhookHandler = async (
req: NextApiRequest,
res: NextApiResponse<StripeWebhookResponse>,
) => {
try {
const isBillingEnabled = await getFlag('app_billing');
if (!isBillingEnabled) {
return res.status(500).json({
success: false,
message: 'Billing is disabled',
});
}
const signature =
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
if (!signature) {
return res.status(400).json({
success: false,
message: 'No signature found in request',
});
}
const body = await buffer(req);
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
);
await match(event.type)
.with('checkout.session.completed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const session = event.data.object as Stripe.Checkout.Session;
const userId = Number(session.client_reference_id);
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription?.id;
if (!subscriptionId || Number.isNaN(userId)) {
return res.status(500).json({
success: false,
message: 'Invalid session',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
await onSubscriptionUpdated({ userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('customer.subscription.updated', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id;
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('invoice.payment_succeeded', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
if (invoice.billing_reason !== 'subscription_cycle') {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.with('invoice.payment_failed', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const invoice = event.data.object as Stripe.Invoice;
const customerId =
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
const subscriptionId =
typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (!customerId || !subscriptionId) {
return res.status(500).json({
success: false,
message: 'Invalid invoice',
});
}
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const result = await prisma.subscription.findFirst({
select: {
userId: true,
},
where: {
customerId,
},
});
if (!result?.userId) {
return res.status(500).json({
success: false,
message: 'User not found',
});
}
await onSubscriptionUpdated({ userId: result.userId, subscription });
})
.with('customer.subscription.deleted', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await onSubscriptionDeleted({ subscription });
return res.status(200).json({
success: true,
message: 'Webhook received',
});
})
.otherwise(() => {
return res.status(200).json({
success: true,
message: 'Webhook received',
});
});
} catch (err) {
console.error(err);
res.status(500).json({
success: false,
message: 'Unknown error',
});
}
};

View File

@ -0,0 +1,21 @@
import { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription;
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
await prisma.subscription.update({
where: {
customerId,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
};

View File

@ -0,0 +1,44 @@
import { match } from 'ts-pattern';
import { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client';
export type OnSubscriptionUpdatedOptions = {
userId: number;
subscription: Stripe.Subscription;
};
export const onSubscriptionUpdated = async ({
userId,
subscription,
}: OnSubscriptionUpdatedOptions) => {
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.upsert({
where: {
customerId,
},
create: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
userId,
},
update: {
customerId,
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000),
},
});
};

View File

@ -60,26 +60,17 @@ export const calculateTextScaleSize = (
*/ */
export function useElementScaleSize( export function useElementScaleSize(
container: { width: number; height: number }, container: { width: number; height: number },
child: RefObject<HTMLElement | null>, text: string,
fontSize: number, fontSize: number,
fontFamily: string, fontFamily: string,
) { ) {
const [scalingFactor, setScalingFactor] = useState(1); const [scalingFactor, setScalingFactor] = useState(1);
useEffect(() => { useEffect(() => {
if (!child.current) { const scaleSize = calculateTextScaleSize(container, text, `${fontSize}px`, fontFamily);
return;
}
const scaleSize = calculateTextScaleSize(
container,
child.current.innerText,
`${fontSize}px`,
fontFamily,
);
setScalingFactor(scaleSize); setScalingFactor(scaleSize);
}, [child, container, fontFamily, fontSize]); }, [text, container, fontFamily, fontSize]);
return scalingFactor; return scalingFactor;
} }

View File

@ -1,5 +1,3 @@
import { Role, User } from '@documenso/prisma/client'; import { Role, User } from '@documenso/prisma/client';
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN); export const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
export { isAdmin };

View File

@ -33,7 +33,8 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"react": "18.2.0", "react": "18.2.0",
"stripe": "^12.7.0", "stripe": "^12.7.0",
"ts-pattern": "^5.0.5" "ts-pattern": "^5.0.5",
"zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",

View File

@ -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),
};
};

View File

@ -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,
},
});
};

View File

@ -9,9 +9,7 @@ export const getUsersWithSubscriptionsCount = async () => {
return await prisma.user.count({ return await prisma.user.count({
where: { where: {
Subscription: { Subscription: {
some: { status: SubscriptionStatus.ACTIVE,
status: SubscriptionStatus.ACTIVE,
},
}, },
}, },
}); });

View File

@ -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,
},
});
};

View File

@ -0,0 +1,9 @@
import { NextRequest } from 'next/server';
export const toNextRequest = (req: Request) => {
const headers = Object.fromEntries(req.headers.entries());
return new NextRequest(req, {
headers: headers,
});
};

View File

@ -0,0 +1,28 @@
import { NextApiResponse } from 'next';
import { NextResponse } from 'next/server';
type NarrowedResponse<T> = T extends NextResponse
? NextResponse
: T extends NextApiResponse<infer U>
? NextApiResponse<U>
: never;
export const withStaleWhileRevalidate = <T>(
res: NarrowedResponse<T>,
cacheInSeconds = 60,
staleCacheInSeconds = 300,
) => {
if ('headers' in res) {
res.headers.set(
'Cache-Control',
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
);
} else {
res.setHeader(
'Cache-Control',
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
);
}
return res;
};

View File

@ -7,7 +7,7 @@ export type GetSubscriptionByUserIdOptions = {
}; };
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => { export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
return prisma.subscription.findFirst({ return await prisma.subscription.findFirst({
where: { where: {
userId, userId,
}, },

View File

@ -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,
},
});
};

View File

@ -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<Prisma.UserWhereInput>()({
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),
};
};

View File

@ -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;

View File

@ -31,7 +31,7 @@ model User {
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
Document Document[] Document Document[]
Subscription Subscription[] Subscription Subscription?
PasswordResetToken PasswordResetToken[] PasswordResetToken PasswordResetToken[]
} }
@ -220,7 +220,7 @@ model DocumentShareLink {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
document Document @relation(fields: [documentId], references: [id]) document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@unique([documentId, email]) @@unique([documentId, email])
} }

View File

@ -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.',
});
}
}),
});

View File

@ -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
>;

View File

@ -1,5 +1,6 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { createDocument } from '@documenso/lib/server-only/document/create-document'; import { createDocument } from '@documenso/lib/server-only/document/create-document';
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document'; import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
@ -63,13 +64,25 @@ export const documentRouter = router({
try { try {
const { title, documentDataId } = input; const { title, documentDataId } = input;
const { remaining } = await getServerLimits({ email: ctx.user.email });
if (remaining.documents <= 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message:
'You have reached your document limit for this month. Please upgrade your plan.',
});
}
return await createDocument({ return await createDocument({
userId: ctx.user.id, userId: ctx.user.id,
title, title,
documentDataId, documentDataId,
}); });
} catch (err) { } catch (err) {
console.error(err); if (err instanceof TRPCError) {
throw err;
}
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',

View File

@ -1,19 +1,34 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password'; 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 { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { updatePassword } from '@documenso/lib/server-only/user/update-password'; import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile'; import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import { import {
ZForgotPasswordFormSchema, ZForgotPasswordFormSchema,
ZResetPasswordFormSchema, ZResetPasswordFormSchema,
ZRetrieveUserByIdQuerySchema,
ZUpdatePasswordMutationSchema, ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema, ZUpdateProfileMutationSchema,
} from './schema'; } from './schema';
export const profileRouter = router({ 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 updateProfile: authenticatedProcedure
.input(ZUpdateProfileMutationSchema) .input(ZUpdateProfileMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@ -1,5 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
export const ZRetrieveUserByIdQuerySchema = z.object({
id: z.number().min(1),
});
export const ZUpdateProfileMutationSchema = z.object({ export const ZUpdateProfileMutationSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
signature: z.string(), signature: z.string(),
@ -19,6 +23,7 @@ export const ZResetPasswordFormSchema = z.object({
token: z.string().min(1), token: z.string().min(1),
}); });
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>; export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>; export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>; export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;

View File

@ -1,3 +1,4 @@
import { adminRouter } from './admin-router/router';
import { authRouter } from './auth-router/router'; import { authRouter } from './auth-router/router';
import { documentRouter } from './document-router/router'; import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router'; import { fieldRouter } from './field-router/router';
@ -13,6 +14,7 @@ export const appRouter = router({
profile: profileRouter, profile: profileRouter,
document: documentRouter, document: documentRouter,
field: fieldRouter, field: fieldRouter,
admin: adminRouter,
shareLink: shareLinkRouter, shareLink: shareLinkRouter,
}); });

View File

@ -1,6 +1,8 @@
import { TRPCError, initTRPC } from '@trpc/server'; import { TRPCError, initTRPC } from '@trpc/server';
import SuperJSON from 'superjson'; import SuperJSON from 'superjson';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { TrpcContext } from './context'; import { TrpcContext } from './context';
const t = initTRPC.context<TrpcContext>().create({ const t = initTRPC.context<TrpcContext>().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 * Routers and Procedures
*/ */
export const router = t.router; export const router = t.router;
export const procedure = t.procedure; export const procedure = t.procedure;
export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware); export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware);
export const adminProcedure = t.procedure.use(adminMiddleware);

View File

@ -5,8 +5,10 @@ import { HTMLAttributes, useState } from 'react';
import { Copy, Share } from 'lucide-react'; import { Copy, Share } from 'lucide-react';
import { FaXTwitter } from 'react-icons/fa6'; import { FaXTwitter } from 'react-icons/fa6';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent'; import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -18,14 +20,12 @@ import {
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard'; export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
export type ShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
token: string; token: string;
documentId: number; documentId: number;
}; };
export const ShareButton = ({ token, documentId }: ShareButtonProps) => { export const DocumentShareButton = ({ token, documentId, className }: DocumentShareButtonProps) => {
const { toast } = useToast(); const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard(); const [, copyToClipboard] = useCopyToClipboard();
@ -60,7 +60,7 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
slug = result.slug; slug = result.slug;
} }
await copyToClipboard(`${window.location.origin}/share/${slug}`).catch(() => null); await copyToClipboard(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`).catch(() => null);
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
@ -85,7 +85,7 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
window.open( window.open(
generateTwitterIntent( generateTwitterIntent(
`I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`, `I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`,
`${window.location.origin}/share/${slug}`, `${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`,
), ),
'_blank', '_blank',
); );
@ -99,7 +99,7 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
<Button <Button
variant="outline" variant="outline"
disabled={!token || !documentId} disabled={!token || !documentId}
className="flex-1" className={cn('flex-1', className)}
loading={isLoading} loading={isLoading}
> >
{!isLoading && <Share className="mr-2 h-5 w-5" />} {!isLoading && <Share className="mr-2 h-5 w-5" />}
@ -120,8 +120,12 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
<span className="font-medium text-blue-400">@documenso</span> <span className="font-medium text-blue-400">@documenso</span>
. Check it out! . Check it out!
<span className="mt-2 block" /> <span className="mt-2 block" />
<span className="break-all font-medium text-blue-400"> <span
{window.location.origin}/share/{shareLink?.slug || '...'} className={cn('break-all font-medium text-blue-400', {
'animate-pulse': !shareLink?.slug,
})}
>
{process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'}
</span> </span>
</div> </div>

View File

@ -56,7 +56,7 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
const sheenGradient = useMotionTemplate`linear-gradient( const sheenGradient = useMotionTemplate`linear-gradient(
30deg, 30deg,
transparent, transparent,
rgba(var(--sheen-color) / ${trackMouse ? sheenOpacity : 0}) ${sheenPosition}%, rgba(var(--sheen-color) / ${sheenOpacity}) ${sheenPosition}%,
transparent)`; transparent)`;
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
@ -98,10 +98,12 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
void animate(cardX, 0, { duration: 2, ease: 'backInOut' }); void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
void animate(cardY, 0, { duration: 2, ease: 'backInOut' }); void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
void animate(sheenOpacity, 0, { duration: 2, ease: 'backInOut' });
setTrackMouse(false); setTrackMouse(false);
}, 1000); }, 1000);
}, },
[cardX, cardY, cardCenterPosition, trackMouse], [cardX, cardY, cardCenterPosition, trackMouse, sheenOpacity],
); );
useEffect(() => { useEffect(() => {
@ -126,7 +128,6 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
transformStyle: 'preserve-3d', transformStyle: 'preserve-3d',
rotateX, rotateX,
rotateY, rotateY,
// willChange: 'transform background-image',
}} }}
> >
<SigningCardContent className="bg-transparent" name={name} /> <SigningCardContent className="bg-transparent" name={name} />

View File

@ -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<string[]>([]);
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder={selectedValues.join(', ')} />
<CommandEmpty>No value found.</CommandEmpty>
<CommandGroup>
{allRoles.map((value: string, i: number) => (
<CommandItem key={i} onSelect={() => handleSelect(value)}>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
)}
/>
{value}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
};
export { Combobox };

View File

@ -74,16 +74,23 @@ const DocumentDropzoneCardCenterVariants: Variants = {
export type DocumentDropzoneProps = { export type DocumentDropzoneProps = {
className?: string; className?: string;
disabled?: boolean;
onDrop?: (_file: File) => void | Promise<void>; onDrop?: (_file: File) => void | Promise<void>;
[key: string]: unknown; [key: string]: unknown;
}; };
export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzoneProps) => { export const DocumentDropzone = ({
className,
onDrop,
disabled,
...props
}: DocumentDropzoneProps) => {
const { getRootProps, getInputProps } = useDropzone({ const { getRootProps, getInputProps } = useDropzone({
accept: { accept: {
'application/pdf': ['.pdf'], 'application/pdf': ['.pdf'],
}, },
multiple: false, multiple: false,
disabled,
onDrop: ([acceptedFile]) => { onDrop: ([acceptedFile]) => {
if (acceptedFile && onDrop) { if (acceptedFile && onDrop) {
void onDrop(acceptedFile); void onDrop(acceptedFile);
@ -102,11 +109,12 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
<Card <Card
role="button" role="button"
className={cn( className={cn(
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2', 'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 aria-disabled:pointer-events-none aria-disabled:opacity-60',
className, className,
)} )}
gradient={true} gradient={true}
degrees={120} degrees={120}
aria-disabled={disabled}
{...getRootProps()} {...getRootProps()}
{...props} {...props}
> >

View File

@ -70,25 +70,23 @@ export function SinglePlayerModeSignatureField({
throw new Error('Invalid field type'); throw new Error('Invalid field type');
} }
const $paragraphEl = useRef<HTMLParagraphElement>(null);
const { height, width } = useFieldPageCoords(field); const { height, width } = useFieldPageCoords(field);
const insertedBase64Signature = field.inserted && field.Signature?.signatureImageAsBase64;
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
const scalingFactor = useElementScaleSize( const scalingFactor = useElementScaleSize(
{ {
height, height,
width, width,
}, },
$paragraphEl, insertedTypeSignature || '',
maxFontSize, maxFontSize,
fontVariableValue, fontVariableValue,
); );
const fontSize = maxFontSize * scalingFactor; const fontSize = maxFontSize * scalingFactor;
const insertedBase64Signature = field.inserted && field.Signature?.signatureImageAsBase64;
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
return ( return (
<SinglePlayerModeFieldCardContainer field={field}> <SinglePlayerModeFieldCardContainer field={field}>
{insertedBase64Signature ? ( {insertedBase64Signature ? (
@ -99,7 +97,6 @@ export function SinglePlayerModeSignatureField({
/> />
) : insertedTypeSignature ? ( ) : insertedTypeSignature ? (
<p <p
ref={$paragraphEl}
style={{ style={{
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`, fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
fontFamily: `var(${fontVariable})`, fontFamily: `var(${fontVariable})`,
@ -145,7 +142,7 @@ export function SinglePlayerModeCustomTextField({
height, height,
width, width,
}, },
$paragraphEl, field.customText,
maxFontSize, maxFontSize,
fontVariableValue, fontVariableValue,
); );

View File

@ -22,10 +22,12 @@ const DPI = 2;
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & { export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
onChange?: (_signatureDataUrl: string | null) => void; onChange?: (_signatureDataUrl: string | null) => void;
containerClassName?: string;
}; };
export const SignaturePad = ({ export const SignaturePad = ({
className, className,
containerClassName,
defaultValue, defaultValue,
onChange, onChange,
...props ...props
@ -210,7 +212,7 @@ export const SignaturePad = ({
}, [defaultValue]); }, [defaultValue]);
return ( return (
<div className="relative block"> <div className={cn('relative block', containerClassName)}>
<canvas <canvas
ref={$el} ref={$el}
className={cn('relative block dark:invert', className)} className={cn('relative block dark:invert', className)}
@ -226,7 +228,7 @@ export const SignaturePad = ({
<div className="absolute bottom-4 right-4"> <div className="absolute bottom-4 right-4">
<button <button
type="button" type="button"
className="focus-visible:ring-ring ring-offset-background rounded-full p-0 text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2" className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
onClick={() => onClearClick()} onClick={() => onClearClick()}
> >
Clear Signature Clear Signature

View File

@ -2,13 +2,8 @@
"$schema": "https://turbo.build/schema.json", "$schema": "https://turbo.build/schema.json",
"pipeline": { "pipeline": {
"build": { "build": {
"dependsOn": [ "dependsOn": ["^build"],
"^build" "outputs": [".next/**", "!.next/cache/**"]
],
"outputs": [
".next/**",
"!.next/cache/**"
]
}, },
"lint": {}, "lint": {},
"clean": { "clean": {
@ -17,11 +12,15 @@
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true "persistent": true
},
"dev:test": {
"cache": false
},
"test:e2e": {
"dependsOn": ["^build"]
} }
}, },
"globalDependencies": [ "globalDependencies": ["**/.env.*local"],
"**/.env.*local"
],
"globalEnv": [ "globalEnv": [
"APP_VERSION", "APP_VERSION",
"NEXTAUTH_URL", "NEXTAUTH_URL",
@ -67,6 +66,7 @@
"NEXT_PRIVATE_SMTP_FROM_NAME", "NEXT_PRIVATE_SMTP_FROM_NAME",
"NEXT_PRIVATE_SMTP_FROM_ADDRESS", "NEXT_PRIVATE_SMTP_FROM_ADDRESS",
"NEXT_PRIVATE_STRIPE_API_KEY", "NEXT_PRIVATE_STRIPE_API_KEY",
"NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET",
"VERCEL", "VERCEL",
"VERCEL_ENV", "VERCEL_ENV",
"VERCEL_URL", "VERCEL_URL",
@ -74,6 +74,9 @@
"POSTGRES_URL", "POSTGRES_URL",
"DATABASE_URL", "DATABASE_URL",
"POSTGRES_PRISMA_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"
] ]
} }