Compare commits

..

29 Commits

Author SHA1 Message Date
Timur Ercan
6f851833b2 Merge branch 'feat/refresh' into feat/admin-ui-manage-instance 2023-10-13 16:44:44 +02:00
Lucas Smith
a6e13faf7b fix: quick tweaks 2023-10-13 13:08:39 +00:00
Lucas Smith
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
David Nguyen
a354c23231 feat: add document share button to marketing (#422) 2023-10-13 14:14:13 +11:00
David Nguyen
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
David Nguyen
c803d2c4ba feat: single-player-mode-polish (#435) 2023-10-12 18:10:52 +11:00
Udit Takkar
eb5f5f7a90 fix: background color of signature page (#487) 2023-10-12 14:08:26 +11:00
Abhinav-Developer-23
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
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
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
64 changed files with 1028 additions and 633 deletions

View File

@@ -68,7 +68,6 @@ NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
# [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags.

View File

@@ -7,7 +7,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@@ -2,6 +2,8 @@
import React, { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { cn } from '@documenso/ui/lib/utils';
import { Footer } from '~/components/(marketing)/footer';
@@ -13,6 +15,7 @@ export type MarketingLayoutProps = {
export default function MarketingLayout({ children }: MarketingLayoutProps) {
const [scrollY, setScrollY] = useState(0);
const pathname = usePathname();
useEffect(() => {
const onScroll = () => {
@@ -25,7 +28,11 @@ export default function MarketingLayout({ children }: MarketingLayoutProps) {
}, []);
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
className={cn('fixed left-0 top-0 z-50 w-full bg-transparent', {
'bg-background/50 backdrop-blur-md': scrollY > 5,

View File

@@ -130,7 +130,7 @@ export default function SinglePlayerModePage() {
signer: data.email,
});
router.push(`/single-player-mode/${documentToken}/success`);
router.push(`/singleplayer/${documentToken}/success`);
} catch {
toast({
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 { getAllAnonymousFlags } from '@documenso/lib/universal/get-feature-flag';
import { TrpcProvider } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Toaster } from '@documenso/ui/primitives/toaster';
@@ -63,7 +64,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body>
<FeatureFlagProvider initialFlags={flags}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<PlausibleProvider>{children}</PlausibleProvider>
<PlausibleProvider>
<TrpcProvider>{children}</TrpcProvider>
</PlausibleProvider>
</ThemeProvider>
</FeatureFlagProvider>

View File

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

View File

@@ -35,7 +35,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
{isSinglePlayerModeMarketingEnabled && (
<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"
>
Try now!

View File

@@ -134,9 +134,9 @@ export const Hero = ({ className, ...props }: HeroProps) => {
variants={HeroTitleVariants}
initial="initial"
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">
Introducing Single Player Mode
</h2>

View File

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

View File

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

View File

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

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

@@ -7,7 +7,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

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 { usePathname } from 'next/navigation';
import { BarChart3, User2 } from 'lucide-react';
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -37,10 +37,40 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
'justify-start md:w-full',
pathname?.startsWith('/admin/users') && 'bg-secondary',
)}
disabled
asChild
>
<User2 className="mr-2 h-5 w-5" />
Users (Coming Soon)
<Link href="/admin/users">
<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>
</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,137 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { TUserFormSchema, ZUserFormSchema } from '~/providers/admin-user-profile-update.types';
export default function UserPage({ params }: { params: { id: number } }) {
const { toast } = useToast();
const router = useRouter();
const { data: user } = trpc.profile.getUser.useQuery(
{
id: Number(params.id),
},
{
enabled: !!params.id,
},
);
const roles = user?.roles ?? [];
const { mutateAsync: updateUserMutation } = trpc.admin.updateUser.useMutation();
const form = useForm<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,155 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import Link from 'next/link';
import { Edit, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { Document, Role, Subscription } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { Input } from '@documenso/ui/primitives/input';
interface User {
id: number;
name: string | null;
email: string;
roles: Role[];
Subscription: SubscriptionLite[];
Document: DocumentLite[];
}
type SubscriptionLite = Pick<
Subscription,
'id' | 'status' | 'planId' | 'priceId' | 'createdAt' | 'periodEnd'
>;
type DocumentLite = Pick<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 }) => {
if (row.original.Subscription && row.original.Subscription.length > 0) {
return (
<>
{row.original.Subscription.map((subscription: SubscriptionLite, i: number) => {
return <span key={i}>{subscription.status}</span>;
})}
</>
);
} else {
return <span>NONE</span>;
}
},
},
{
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 { 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 { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type DataTableActionButtonProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
@@ -47,7 +46,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
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({
title: 'Copied to clipboard',

View File

@@ -18,6 +18,7 @@ import {
} from 'lucide-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 { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
@@ -32,8 +33,6 @@ import {
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
export type DataTableActionDropdownProps = {
@@ -61,7 +60,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
const isOwner = row.User.id === session.user.id;
// const isRecipient = !!recipient;
// const isDraft = row.status === DocumentStatus.DRAFT;
const isDraft = row.status === DocumentStatus.DRAFT;
// const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
@@ -73,7 +72,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
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({
title: 'Copied to clipboard',
@@ -166,7 +165,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
Resend
</DropdownMenuItem>
<DropdownMenuItem onClick={onShareClick}>
<DropdownMenuItem disabled={isDraft} onClick={onShareClick}>
{isCreatingShareLink ? (
<Loader className="mr-2 h-4 w-4" />
) : (

View File

@@ -1,133 +0,0 @@
'use client';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent, CardTitle } from '@documenso/ui/primitives/card';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createCheckout } from './create-checkout.action';
type Interval = keyof PriceIntervals;
const INTERVALS: Interval[] = ['day', 'week', 'month', 'year'];
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const isInterval = (value: unknown): value is Interval => INTERVALS.includes(value as Interval);
const FRIENDLY_INTERVALS: Record<Interval, string> = {
day: 'Daily',
week: 'Weekly',
month: 'Monthly',
year: 'Yearly',
};
const MotionCard = motion(Card);
export type BillingPlansProps = {
prices: PriceIntervals;
};
export const BillingPlans = ({ prices }: BillingPlansProps) => {
const { toast } = useToast();
const isMounted = useIsMounted();
const [interval, setInterval] = useState<Interval>('month');
const [isFetchingCheckoutSession, setIsFetchingCheckoutSession] = useState(false);
const onSubscribeClick = async (priceId: string) => {
try {
setIsFetchingCheckoutSession(true);
const url = await createCheckout({ priceId });
if (!url) {
throw new Error('Unable to create session');
}
window.open(url);
} catch (_err) {
toast({
title: 'Something went wrong',
description: 'An error occurred while trying to create a checkout session.',
variant: 'destructive',
});
} finally {
setIsFetchingCheckoutSession(false);
}
};
return (
<div>
<Tabs value={interval} onValueChange={(value) => isInterval(value) && setInterval(value)}>
<TabsList>
{INTERVALS.map(
(interval) =>
prices[interval].length > 0 && (
<TabsTrigger key={interval} className="min-w-[150px]" value={interval}>
{FRIENDLY_INTERVALS[interval]}
</TabsTrigger>
),
)}
</TabsList>
</Tabs>
<div className="mt-8 grid gap-8 lg:grid-cols-2 2xl:grid-cols-3">
<AnimatePresence mode="wait">
{prices[interval].map((price) => (
<MotionCard
key={price.id}
initial={{ opacity: isMounted ? 0 : 1, y: isMounted ? 20 : 0 }}
animate={{ opacity: 1, y: 0, transition: { duration: 0.3 } }}
exit={{ opacity: 0, transition: { duration: 0.3 } }}
>
<CardContent className="flex h-full flex-col p-6">
<CardTitle>{price.product.name}</CardTitle>
<div className="text-muted-foreground mt-2 text-lg font-medium">
${toHumanPrice(price.unit_amount ?? 0)} {price.currency.toUpperCase()}{' '}
<span className="text-xs">per {interval}</span>
</div>
<div className="text-muted-foreground mt-1.5 text-sm">
{price.product.description}
</div>
{price.product.features && price.product.features.length > 0 && (
<div className="text-muted-foreground mt-4">
<div className="text-sm font-medium">Includes:</div>
<ul className="mt-1 divide-y text-sm">
{price.product.features.map((feature, index) => (
<li key={index} className="py-2">
{feature.name}
</li>
))}
</ul>
</div>
)}
<div className="flex-1" />
<Button
className="mt-4"
loading={isFetchingCheckoutSession}
onClick={() => void onSubscribeClick(price.id)}
>
Subscribe
</Button>
</CardContent>
</MotionCard>
))}
</AnimatePresence>
</div>
</div>
);
};

View File

@@ -1,55 +0,0 @@
'use client';
import { useState } from 'react';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createBillingPortal } from './create-billing-portal.action';
export const BillingPortalButton = () => {
const { toast } = useToast();
const [isFetchingPortalUrl, setIsFetchingPortalUrl] = useState(false);
const handleFetchPortalUrl = async () => {
if (isFetchingPortalUrl) {
return;
}
setIsFetchingPortalUrl(true);
try {
const sessionUrl = await createBillingPortal();
if (!sessionUrl) {
throw new Error('NO_SESSION');
}
window.open(sessionUrl, '_blank');
} catch (e) {
let description =
'We are unable to proceed to the billing portal at this time. Please try again, or contact support.';
if (e.message === 'CUSTOMER_NOT_FOUND') {
description =
'You do not currently have a customer record, this should not happen. Please contact support for assistance.';
}
toast({
title: 'Something went wrong',
description,
variant: 'destructive',
duration: 10000,
});
}
setIsFetchingPortalUrl(false);
};
return (
<Button onClick={async () => handleFetchPortalUrl()} loading={isFetchingPortalUrl}>
Manage Subscription
</Button>
);
};

View File

@@ -1,48 +0,0 @@
'use server';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export const createBillingPortal = async () => {
const { user } = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
};

View File

@@ -1,59 +0,0 @@
'use server';
import { getCheckoutSession } from '@documenso/ee/server-only/stripe/get-checkout-session';
import {
getStripeCustomerByEmail,
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export type CreateCheckoutOptions = {
priceId: string;
};
export const createCheckout = async ({ priceId }: CreateCheckoutOptions) => {
const { user } = await getRequiredServerComponentSession();
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
let stripeCustomer: Stripe.Customer | null = null;
// Find the Stripe customer for the current user subscription.
if (existingSubscription) {
stripeCustomer = await getStripeCustomerById(existingSubscription.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer for subscription');
}
return getPortalSession({
customerId: stripeCustomer.id,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
// Fallback to check if a Stripe customer already exists for the current user email.
if (!stripeCustomer) {
stripeCustomer = await getStripeCustomerByEmail(user.email);
}
// Create a Stripe customer if it does not exist for the current user.
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
return getCheckoutSession({
customerId: stripeCustomer.id,
priceId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
};

View File

@@ -1,18 +1,16 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-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 { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { LocaleDate } from '~/components/formatter/locale-date';
import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button';
export default async function BillingSettingsPage() {
const { user } = await getRequiredServerComponentSession();
@@ -23,75 +21,57 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile');
}
const [subscription, prices] = await Promise.all([
getSubscriptionByUserId({ userId: user.id }),
getPricesByInterval(),
]);
const subscription = await getSubscriptionByUserId({ userId: user.id }).then(async (sub) => {
if (sub) {
return sub;
}
let subscriptionProduct: Stripe.Product | null = null;
// If we don't have a customer record, create one as well as an empty subscription.
return createCustomer({ user });
});
if (subscription?.planId) {
const foundSubscriptionProduct = (await stripe.products.list()).data.find(
(item) => item.default_price === subscription.planId,
);
let billingPortalUrl = '';
subscriptionProduct = foundSubscriptionProduct ?? null;
if (subscription.customerId) {
billingPortalUrl = await getPortalSession({
customerId: subscription.customerId,
returnUrl: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/settings/billing`,
});
}
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE';
return (
<div>
<h3 className="text-lg font-medium">Billing</h3>
<div className="text-muted-foreground mt-2 text-sm">
{isMissingOrInactiveOrFreePlan && (
<p>
You are currently on the <span className="font-semibold">Free Plan</span>.
</p>
<p className="text-muted-foreground mt-2 text-sm">
Your subscription is{' '}
{subscription.status !== SubscriptionStatus.INACTIVE ? 'active' : 'inactive'}.
{subscription?.periodEnd && (
<>
{' '}
Your next payment is due on{' '}
<span className="font-semibold">
<LocaleDate date={subscription.periodEnd} />
</span>
.
</>
)}
{!isMissingOrInactiveOrFreePlan &&
match(subscription.status)
.with('ACTIVE', () => (
<p>
{subscriptionProduct ? (
<span>
You are currently subscribed to{' '}
<span className="font-semibold">{subscriptionProduct.name}</span>
</span>
) : (
<span>You currently have an active plan</span>
)}
{subscription.periodEnd && (
<span>
{' '}
which is set to{' '}
{subscription.cancelAtPeriodEnd ? (
<span>
end on{' '}
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
</span>
) : (
<span>
automatically renew on{' '}
<LocaleDate className="font-semibold" date={subscription.periodEnd} />.
</span>
)}
</span>
)}
</p>
))
.with('PAST_DUE', () => (
<p>Your current plan is past due. Please update your payment information.</p>
))
.otherwise(() => null)}
</div>
</p>
<hr className="my-4" />
{isMissingOrInactiveOrFreePlan ? <BillingPlans prices={prices} /> : <BillingPortalButton />}
{billingPortalUrl && (
<Button asChild>
<Link href={billingPortalUrl}>Manage Subscription</Link>
</Button>
)}
{!billingPortalUrl && (
<p className="text-muted-foreground max-w-[60ch] text-base">
You do not currently have a customer record, this should not happen. Please contact
support for assistance.
</p>
)}
</div>
);
}

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 { DocumentStatus, FieldType } from '@documenso/prisma/client';
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 signingCelebration from '~/assets/signing-celebration.png';
import { ShareButton } from './share-button';
export type CompletedSigningPageProps = {
params: {
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">
<ShareButton documentId={document.id} token={recipient.token} />
<DocumentShareButton documentId={document.id} token={recipient.token} />
<DocumentDownloadButton
className="flex-1"

View File

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

View File

@@ -117,7 +117,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
name="signature"
render={({ field: { onChange } }) => (
<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}
onChange={(v) => onChange(v ?? '')}
/>

View File

@@ -147,7 +147,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
name="signature"
render={({ field: { onChange } }) => (
<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 ?? '')}
/>
)}

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

@@ -3,7 +3,6 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { buffer } from 'micro';
import { match } from 'ts-pattern';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
@@ -17,7 +16,6 @@ import {
ReadStatus,
SendStatus,
SigningStatus,
SubscriptionStatus,
} from '@documenso/prisma/client';
const log = (...args: unknown[]) => console.log('[stripe]', ...args);
@@ -56,18 +54,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
);
log('event-type:', event.type);
if (event.type === 'customer.subscription.updated') {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await handleCustomerSubscriptionUpdated(subscription);
return res.status(200).json({
success: true,
message: 'Webhook received',
});
}
if (event.type === 'checkout.session.completed') {
// This is required since we don't want to create a guard for every event type
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
@@ -209,29 +195,3 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
message: 'Unhandled webhook event',
});
}
const handleCustomerSubscriptionUpdated = async (subscription: Stripe.Subscription) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const { plan } = subscription as unknown as Stripe.SubscriptionItem;
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
await prisma.subscription.update({
where: {
customerId: customerId,
},
data: {
planId: plan.id,
status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
periodEnd: new Date(subscription.current_period_end * 1000),
updatedAt: new Date(),
},
});
};

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
export const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
export type TUserFormSchema = z.infer<typeof ZUserFormSchema>;

View File

@@ -1,31 +0,0 @@
'use server';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
};
export const getCheckoutSession = async ({
customerId,
priceId,
returnUrl,
}: GetCheckoutSessionOptions) => {
'use server';
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
});
return session.url;
};

View File

@@ -1,19 +0,0 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({
email,
});
return foundStripeCustomers.data[0] ?? null;
};
export const getStripeCustomerById = async (stripeCustomerId: string) => {
try {
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
return !stripeCustomer.deleted ? stripeCustomer : null;
} catch {
return null;
}
};

View File

@@ -1,40 +0,0 @@
import Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
// Utility type to handle usage of the `expand` option.
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export const getPricesByInterval = async () => {
const { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
const intervals: PriceIntervals = {
day: [],
week: [],
month: [],
year: [],
};
// Add each price to the correct interval.
for (const price of prices) {
if (price.recurring?.interval) {
// We use `expand` to get the product, but it's not typed as part of the Price type.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
intervals[price.recurring.interval].push(price as PriceWithProduct);
}
}
// Order all prices by unit_amount.
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
return intervals;
};

View File

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

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

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

@@ -1,4 +1,3 @@
/// <reference types="./stripe.d.ts" />
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', {

View File

@@ -1,7 +0,0 @@
declare module 'stripe' {
namespace Stripe {
interface Product {
features?: Array<{ name: string }>;
}
}
}

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

@@ -1,3 +0,0 @@
export const toHumanPrice = (price: number) => {
return Number(price / 100).toFixed(2);
};

View File

@@ -1,17 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[userId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
- Made the column `customerId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
*/
DELETE FROM "Subscription"
WHERE "customerId" IS NULL;
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "customerId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");

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

@@ -51,16 +51,15 @@ enum SubscriptionStatus {
}
model Subscription {
id Int @id @default(autoincrement())
status SubscriptionStatus @default(INACTIVE)
planId String?
priceId String?
customerId String
periodEnd DateTime?
userId Int @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cancelAtPeriodEnd Boolean @default(false)
id Int @id @default(autoincrement())
status SubscriptionStatus @default(INACTIVE)
planId String?
priceId String?
customerId String?
periodEnd DateTime?
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -220,7 +219,7 @@ model DocumentShareLink {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
document Document @relation(fields: [documentId], references: [id])
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@unique([documentId, email])
}

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,19 +1,34 @@
import { TRPCError } from '@trpc/server';
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { authenticatedProcedure, procedure, router } from '../trpc';
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
import {
ZForgotPasswordFormSchema,
ZResetPasswordFormSchema,
ZRetrieveUserByIdQuerySchema,
ZUpdatePasswordMutationSchema,
ZUpdateProfileMutationSchema,
} from './schema';
export const profileRouter = router({
getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => {
try {
const { id } = input;
return await getUserById({ id });
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to retrieve the specified account. Please try again.',
});
}
}),
updateProfile: authenticatedProcedure
.input(ZUpdateProfileMutationSchema)
.mutation(async ({ input, ctx }) => {

View File

@@ -1,5 +1,9 @@
import { z } from 'zod';
export const ZRetrieveUserByIdQuerySchema = z.object({
id: z.number().min(1),
});
export const ZUpdateProfileMutationSchema = z.object({
name: z.string().min(1),
signature: z.string(),
@@ -19,6 +23,7 @@ export const ZResetPasswordFormSchema = z.object({
token: z.string().min(1),
});
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
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 { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router';
@@ -13,6 +14,7 @@ export const appRouter = router({
profile: profileRouter,
document: documentRouter,
field: fieldRouter,
admin: adminRouter,
shareLink: shareLinkRouter,
});

View File

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

View File

@@ -10,7 +10,6 @@ declare namespace NodeJS {
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
NEXT_PRIVATE_STRIPE_API_KEY: string;
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;

View File

@@ -5,8 +5,10 @@ import { HTMLAttributes, useState } from 'react';
import { Copy, Share } from 'lucide-react';
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 { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -18,14 +20,12 @@ import {
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCopyToClipboard } from '~/hooks/use-copy-to-clipboard';
export type ShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
token: string;
documentId: number;
};
export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
export const DocumentShareButton = ({ token, documentId, className }: DocumentShareButtonProps) => {
const { toast } = useToast();
const [, copyToClipboard] = useCopyToClipboard();
@@ -60,7 +60,7 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
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({
title: 'Copied to clipboard',
@@ -85,7 +85,7 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
window.open(
generateTwitterIntent(
`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',
);
@@ -99,7 +99,7 @@ export const ShareButton = ({ token, documentId }: ShareButtonProps) => {
<Button
variant="outline"
disabled={!token || !documentId}
className="flex-1"
className={cn('flex-1', className)}
loading={isLoading}
>
{!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>
. Check it out!
<span className="mt-2 block" />
<span className="break-all font-medium text-blue-400">
{window.location.origin}/share/{shareLink?.slug || '...'}
<span
className={cn('break-all font-medium text-blue-400', {
'animate-pulse': !shareLink?.slug,
})}
>
{process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'}
</span>
</div>

View File

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

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

View File

@@ -22,10 +22,12 @@ const DPI = 2;
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
onChange?: (_signatureDataUrl: string | null) => void;
containerClassName?: string;
};
export const SignaturePad = ({
className,
containerClassName,
defaultValue,
onChange,
...props
@@ -210,7 +212,7 @@ export const SignaturePad = ({
}, [defaultValue]);
return (
<div className="relative block">
<div className={cn('relative block', containerClassName)}>
<canvas
ref={$el}
className={cn('relative block dark:invert', className)}
@@ -226,7 +228,7 @@ export const SignaturePad = ({
<div className="absolute bottom-4 right-4">
<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()}
>
Clear Signature

View File

@@ -34,7 +34,6 @@
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
@@ -76,4 +75,4 @@
"POSTGRES_PRISMA_URL",
"POSTGRES_URL_NON_POOLING"
]
}
}