Compare commits

..

58 Commits

Author SHA1 Message Date
Mythie
93962625ed fix: further stash conflicts 2023-08-30 16:39:35 +10:00
Mythie
249211bd4f fix: add items from stash 2023-08-30 16:36:22 +10:00
Mythie
bfe0d50661 feat: make billing page functional 2023-08-30 16:15:13 +10:00
Lucas Smith
fead48c2f0 Merge pull request #314 from adithyaakrishna/fix/whitespace
fix: removed unnecessary whitespace before className
2023-08-30 12:36:58 +10:00
Mythie
0abd3da7fd fix: update eslint rules 2023-08-30 12:35:43 +10:00
Lucas Smith
3df0f61947 Merge branch 'feat/refresh' into fix/whitespace 2023-08-30 12:02:01 +10:00
Lucas Smith
8e42dcb7ee Merge pull request #323 from documenso/feat/promise-safety
feat: promise safety
2023-08-30 11:32:32 +10:00
Lucas Smith
1888ee97e6 Merge pull request #296 from documenso/feat/inbox
feat: add inbox
2023-08-30 10:27:32 +10:00
Lucas Smith
068aef665d Merge pull request #328 from nsylke/nsylke-patch-8
chore: change root package.json name
2023-08-30 10:10:42 +10:00
nsylke
2772fc1678 chore: change root package.json name 2023-08-29 16:19:56 -05:00
Mythie
8c4120f0a2 fix: remove further unused code 2023-08-29 18:12:46 +10:00
Mythie
9f93af6134 fix: remove unused code 2023-08-29 17:26:19 +10:00
Mythie
68a5a9da1e feat: add data table actions 2023-08-29 14:33:07 +10:00
Mythie
1f8d5e45e1 feat: onepage inbox 2023-08-29 14:33:05 +10:00
David Nguyen
8fd9730e2b feat: add inbox 2023-08-29 14:31:13 +10:00
Lucas Smith
04f6df6839 Merge pull request #304 from G3root/fix-overscroll
fix: tab overflow on smaller viewport and tab scroll ux
2023-08-29 13:26:26 +10:00
Mythie
ca40e983e3 feat: promise safety with eslint 2023-08-29 13:01:19 +10:00
Lucas Smith
ba054ae915 Merge pull request #319 from documenso/chore/reduce-refetch-time
chore: reduce open page caching time to 1 hour
2023-08-28 22:58:05 +10:00
Ephraim Atta-Duncan
1d1c6e5a55 chore: reduce open page caching time to 1 hour 2023-08-28 09:43:39 +00:00
Adithya Krishna
e8336ae9b4 chore: removed eslint-plugin-import
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-28 12:34:29 +05:30
Adithya Krishna
aad52a3e2e fix: updated eslint config
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-28 12:34:29 +05:30
Adithya Krishna
829122c486 feat: added eslint plugin dependencies
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-28 12:34:29 +05:30
Adithya Krishna
090752c539 feat: added new eslint rules
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-28 12:34:29 +05:30
Adithya Krishna
fad6414995 fix: duplicate import
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-28 12:34:29 +05:30
Adithya Krishna
c817c67a1c fix: removed more unnecessary whitespace
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-28 12:34:29 +05:30
Adithya Krishna
c7001e62f3 fix: removed unnecessary whitespace
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-28 12:34:29 +05:30
Lucas Smith
bf71d2a14e Merge pull request #305 from G3root/responsive-signing-card
fix: make signing form card responsive
2023-08-28 13:07:46 +10:00
Lucas Smith
163911255e Merge pull request #308 from adithyaakrishna/feat/pr-validate
feat: pr title validator workflow
2023-08-28 12:21:36 +10:00
Lucas Smith
24e38a3bbc Merge pull request #309 from nsylke/nsylke-patch-7
feat: set min/max lengths and autocomplete for password
2023-08-28 12:21:08 +10:00
Lucas Smith
dfd714f16a Merge pull request #310 from adithyaakrishna/feat/add-sharp
feat: added sharp package for NextJS13 image optimizations
2023-08-28 12:19:33 +10:00
Mythie
722081f89e fix: dependency ordering 2023-08-28 12:14:15 +10:00
Lucas Smith
f0e1df22b8 Merge pull request #312 from G3root/fix-auth
fix: authentication allowing any password
2023-08-28 12:04:07 +10:00
nafees nazik
615cb263fb fix: authentication 2023-08-27 07:10:41 +05:30
nafees nazik
18faaf49d9 fix: style 2023-08-27 06:26:49 +05:30
Adithya Krishna
650b69ae56 feat: added sharp for image optimizations on nextjs
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 21:23:30 +05:30
Adithya Krishna
eb4be963e3 fix: added eol to workflow file
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 20:57:02 +05:30
Adithya Krishna
27c27743e3 chore: updated workflow name
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 20:57:02 +05:30
Adithya Krishna
92930a2f63 feat: added pr title validator
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 20:57:02 +05:30
nsylke
7ad3365b0e feat: add autocomplete for password managers 2023-08-26 10:22:44 -05:00
nsylke
f8bf4fea36 feat: set min/max lengths for password 2023-08-26 09:53:58 -05:00
Lucas Smith
10cd8144eb Merge pull request #307 from adithyaakrishna/feat/optimize-images
chore: optimize images
2023-08-26 20:06:48 +10:00
Adithya Krishna
66973a3745 chore: optimized images to save ~8mb
Signed-off-by: Adithya Krishna <adikrish@redhat.com>
2023-08-26 13:13:30 +05:30
nafees nazik
85677bb792 fix: make signing form card responsive 2023-08-26 08:47:19 +05:30
nafees nazik
7ae99d2038 fix: overflow and scroll ux 2023-08-26 07:22:51 +05:30
Mythie
70a5105783 fix: add missing await on mail send 2023-08-25 18:33:24 +10:00
Mythie
420372ac9e fix: nicer dark mode for stack avatars 2023-08-25 17:52:58 +10:00
Mythie
6b00282a87 chore: support direct urls for prisma 2023-08-25 17:52:24 +10:00
Lucas Smith
dae1001cbb Merge pull request #300 from documenso/feat/update-document-flow
feat: update document flow
2023-08-25 12:11:43 +10:00
David Nguyen
af81d99b2a refactor: remove whitespace 2023-08-25 11:43:41 +10:00
David Nguyen
2751adc463 feat: update document flow
- Fixed z-index when dragging pre-existing fields
- Refactored document flow
- Added button spinner
- Added animation for document flow slider
- Updated drag and drop fields
- Updated document flow so it adjusts to the height of the PDF
- Updated claim plan dialog
2023-08-25 11:43:41 +10:00
Lucas Smith
396ce9f3f3 Merge pull request #295 from nsylke/nsylke-patch-6
fix: use -p cli option for next dev
2023-08-25 11:29:44 +10:00
Lucas Smith
3f4f66d878 Merge pull request #298 from adithyaakrishna/fix/dependabot
fix: dependabot workflow
2023-08-25 11:28:12 +10:00
Adithya Krishna
d6751d7a26 fix: dependabot workflow
Signed-off-by: Adithya Krishna <aadithya794@gmail.com>
2023-08-24 05:37:17 +00:00
Mythie
0e32baff0b feat: change document view upon completion 2023-08-24 13:31:50 +10:00
nsylke
f76bf4c2c7 fix: use -p cli option for next dev 2023-08-23 17:56:12 -05:00
Lucas Smith
0d8532ab6d Merge pull request #293 from documenso/feat/refactor-shared-components
refactor: extract common components into UI package
2023-08-23 14:08:23 +10:00
Lucas Smith
490d3d51e1 Merge pull request #290 from nsylke/nsylke-patch-5
feat: create robots.txt and sitemap.xml
2023-08-23 13:56:46 +10:00
Nicholas Sylke
04f9422f24 feat: robots.txt & sitemap.xml 2023-08-21 21:41:19 -05:00
119 changed files with 2405 additions and 1093 deletions

View File

@@ -12,6 +12,8 @@ NEXT_PUBLIC_APP_URL="http://localhost:3000"
# [[DATABASE]] # [[DATABASE]]
NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso" NEXT_PRIVATE_DATABASE_URL="postgres://documenso:password@127.0.0.1:54320/documenso"
# 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"
# [[SMTP]] # [[SMTP]]
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels # OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels

7
.eslintignore Normal file
View File

@@ -0,0 +1,7 @@
# Config files
*.config.js
*.config.cjs
# Statically hosted javascript files
apps/*/public/*.js
apps/*/public/*.cjs

View File

@@ -1,12 +1,5 @@
version: 2 version: 2
on:
push:
branches: [ "feat/refresh" ]
pull_request:
branches: [ "feat/refresh" ]
workflow_dispatch:
updates: updates:
- package-ecosystem: 'github-actions' - package-ecosystem: 'github-actions'
directory: '/' directory: '/'

View File

@@ -0,0 +1,21 @@
name: "Validate PR Name"
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
permissions:
pull-requests: read
jobs:
validate-pr:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"dev": "PORT=3001 next dev", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@@ -30,6 +30,7 @@
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"recharts": "^2.7.2", "recharts": "^2.7.2",
"sharp": "0.32.5",
"typescript": "5.1.6", "typescript": "5.1.6",
"zod": "^3.21.4" "zod": "^3.21.4"
}, },

View File

@@ -5,7 +5,7 @@ import { allDocuments } from 'contentlayer/generated';
import type { MDXComponents } from 'mdx/types'; import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks'; import { useMDXComponent } from 'next-contentlayer/hooks';
export const generateStaticParams = async () => export const generateStaticParams = () =>
allDocuments.map((post) => ({ post: post._raw.flattenedPath })); allDocuments.map((post) => ({ post: post._raw.flattenedPath }));
export const generateMetadata = ({ params }: { params: { content: string } }) => { export const generateMetadata = ({ params }: { params: { content: string } }) => {

View File

@@ -7,7 +7,7 @@ import { ChevronLeft } from 'lucide-react';
import type { MDXComponents } from 'mdx/types'; import type { MDXComponents } from 'mdx/types';
import { useMDXComponent } from 'next-contentlayer/hooks'; import { useMDXComponent } from 'next-contentlayer/hooks';
export const generateStaticParams = async () => export const generateStaticParams = () =>
allBlogPosts.map((post) => ({ post: post._raw.flattenedPath })); allBlogPosts.map((post) => ({ post: post._raw.flattenedPath }));
export const generateMetadata = ({ params }: { params: { post: string } }) => { export const generateMetadata = ({ params }: { params: { post: string } }) => {

View File

@@ -8,7 +8,7 @@ import { FundingRaised } from './funding-raised';
import { GithubMetric } from './gh-metrics'; import { GithubMetric } from './gh-metrics';
import { TeamMembers } from './team-members'; import { TeamMembers } from './team-members';
export const revalidate = 86400; export const revalidate = 3600;
const ZGithubStatsResponse = z.object({ const ZGithubStatsResponse = z.object({
stargazers_count: z.number(), stargazers_count: z.number(),
@@ -43,7 +43,7 @@ export default async function OpenPage() {
accept: 'application/vnd.github.v3+json', accept: 'application/vnd.github.v3+json',
}, },
}) })
.then((res) => res.json()) .then(async (res) => res.json())
.then((res) => ZGithubStatsResponse.parse(res)); .then((res) => ZGithubStatsResponse.parse(res));
const { total_count: mergedPullRequests } = await fetch( const { total_count: mergedPullRequests } = await fetch(
@@ -54,7 +54,7 @@ export default async function OpenPage() {
}, },
}, },
) )
.then((res) => res.json()) .then(async (res) => res.json())
.then((res) => ZMergedPullRequestsResponse.parse(res)); .then((res) => ZMergedPullRequestsResponse.parse(res));
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', { const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
@@ -62,7 +62,7 @@ export default async function OpenPage() {
accept: 'application/json', accept: 'application/json',
}, },
}) })
.then((res) => res.json()) .then(async (res) => res.json())
.then((res) => ZStargazersLiveResponse.parse(res)); .then((res) => ZStargazersLiveResponse.parse(res));
return ( return (

View File

@@ -24,7 +24,7 @@ export default async function IndexPage() {
accept: 'application/vnd.github.v3+json', accept: 'application/vnd.github.v3+json',
}, },
}) })
.then((res) => res.json()) .then(async (res) => res.json())
.then((res) => (typeof res.stargazers_count === 'number' ? res.stargazers_count : undefined)) .then((res) => (typeof res.stargazers_count === 'number' ? res.stargazers_count : undefined))
.catch(() => undefined); .catch(() => undefined);

View File

@@ -0,0 +1,14 @@
import { MetadataRoute } from 'next';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/*',
disallow: ['/_next/*'],
},
sitemap: `${getBaseUrl()}/sitemap.xml`,
};
}

View File

@@ -0,0 +1,41 @@
import { MetadataRoute } from 'next';
import { allBlogPosts, allGenericPages } from 'contentlayer/generated';
import { getBaseUrl } from '@documenso/lib/universal/get-base-url';
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = getBaseUrl();
const lastModified = new Date();
return [
{
url: baseUrl,
lastModified,
},
...allGenericPages.map((doc) => ({
url: `${baseUrl}/${doc._raw.flattenedPath}`,
lastModified,
})),
{
url: `${baseUrl}/blog`,
lastModified,
},
...allBlogPosts.map((doc) => ({
url: `${baseUrl}/${doc._raw.flattenedPath}`,
lastModified,
})),
{
url: `${baseUrl}/open`,
lastModified,
},
{
url: `${baseUrl}/oss-friends`,
lastModified,
},
{
url: `${baseUrl}/pricing`,
lastModified,
},
];
}

View File

@@ -5,7 +5,7 @@ import React, { useState } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Info, Loader } from 'lucide-react'; import { Info } from 'lucide-react';
import { usePlausible } from 'next-plausible'; import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@@ -63,7 +63,9 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => { const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
try { try {
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000)); const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
const [redirectUrl] = await Promise.all([ const [redirectUrl] = await Promise.all([
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }), claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
@@ -85,7 +87,7 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent> <DialogContent>
@@ -97,50 +99,49 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form <form onSubmit={handleSubmit(onFormSubmit)}>
className={cn('flex flex-col gap-y-4', className)} <fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
onSubmit={handleSubmit(onFormSubmit)} {params?.get('cancelled') === 'true' && (
> <div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
{params?.get('cancelled') === 'true' && ( <div className="flex">
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4"> <div className="flex-shrink-0">
<div className="flex"> <Info className="h-5 w-5 text-yellow-400" />
<div className="flex-shrink-0"> </div>
<Info className="h-5 w-5 text-yellow-400" /> <div className="ml-3">
</div> <p className="text-sm leading-5 text-yellow-700">
<div className="ml-3"> You have cancelled the payment process. If you didn't mean to do this, please
<p className="text-sm leading-5 text-yellow-700"> try again.
You have cancelled the payment process. If you didn't mean to do this, please </p>
try again. </div>
</p>
</div> </div>
</div> </div>
)}
<div>
<Label className="text-slate-500">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div> </div>
)}
<div> <div>
<Label className="text-slate-500">Name</Label> <Label className="text-slate-500">Email</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus /> <Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.name} /> <FormErrorMessage className="mt-1" error={errors.email} />
</div> </div>
<div> <Button type="submit" size="lg" loading={isSubmitting}>
<Label className="text-slate-500">Email</Label> Claim the Community Plan (
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
<Input type="email" className="mt-2" {...register('email')} /> {planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
<FormErrorMessage className="mt-1" error={errors.email} /> : 'Yearly'}
</div> )
</Button>
<Button type="submit" size="lg" disabled={isSubmitting}> </fieldset>
{isSubmitting && <Loader className="mr-2 h-4 w-4 animate-spin" />}
Claim the Community Plan ({/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -13,7 +13,7 @@ export const PasswordReveal = ({ password }: PasswordRevealProps) => {
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const onCopyClick = () => { const onCopyClick = () => {
copy(password).then(() => { void copy(password).then(() => {
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
description: 'Your password has been copied to your clipboard.', description: 'Your password has been copied to your clipboard.',

View File

@@ -124,7 +124,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
setValue('signatureDataUrl', draftSignatureDataUrl); setValue('signatureDataUrl', draftSignatureDataUrl);
setValue('signatureText', ''); setValue('signatureText', '');
trigger('signatureDataUrl'); void trigger('signatureDataUrl');
setShowSigningDialog(false); setShowSigningDialog(false);
}; };
@@ -135,7 +135,9 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
signatureText, signatureText,
}: TWidgetFormSchema) => { }: TWidgetFormSchema) => {
try { try {
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000)); const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
// eslint-disable-next-line turbo/no-undeclared-env-vars // eslint-disable-next-line turbo/no-undeclared-env-vars
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;

View File

@@ -4,13 +4,14 @@
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
"dev": "PORT=3000 next dev", "dev": "next dev -p 3000",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs" "copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
}, },
"dependencies": { "dependencies": {
"@documenso/ee": "*",
"@documenso/lib": "*", "@documenso/lib": "*",
"@documenso/prisma": "*", "@documenso/prisma": "*",
"@documenso/tailwind-config": "*", "@documenso/tailwind-config": "*",
@@ -21,6 +22,7 @@
"formidable": "^2.1.1", "formidable": "^2.1.1",
"framer-motion": "^10.12.8", "framer-motion": "^10.12.8",
"lucide-react": "^0.214.0", "lucide-react": "^0.214.0",
"luxon": "^3.4.0",
"micro": "^10.0.1", "micro": "^10.0.1",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",
"next": "13.4.12", "next": "13.4.12",
@@ -36,12 +38,14 @@
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.8.0", "react-icons": "^4.8.0",
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"sharp": "0.32.5",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"typescript": "5.1.6", "typescript": "5.1.6",
"zod": "^3.21.4" "zod": "^3.21.4"
}, },
"devDependencies": { "devDependencies": {
"@types/formidable": "^2.0.6", "@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "20.1.0", "@types/node": "20.1.0",
"@types/react": "18.2.18", "@types/react": "18.2.18",
"@types/react-dom": "18.2.7" "@types/react-dom": "18.2.7"

View File

@@ -22,14 +22,14 @@ import { LocaleDate } from '~/components/formatter/locale-date';
import { UploadDocument } from './upload-document'; import { UploadDocument } from './upload-document';
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await getRequiredServerComponentSession(); const user = await getRequiredServerComponentSession();
const [stats, results] = await Promise.all([ const [stats, results] = await Promise.all([
getStats({ getStats({
userId: session.id, user,
}), }),
findDocuments({ findDocuments({
userId: session.id, userId: user.id,
perPage: 10, perPage: 10,
}), }),
]); ]);

View File

@@ -13,6 +13,11 @@ import { AddSignersFormPartial } from '@documenso/ui/primitives/document-flow/ad
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types'; import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject'; import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/add-subject';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -28,6 +33,8 @@ export type EditDocumentFormProps = {
fields: Field[]; fields: Field[];
}; };
type EditDocumentStep = 'signers' | 'fields' | 'subject';
export const EditDocumentForm = ({ export const EditDocumentForm = ({
className, className,
document, document,
@@ -38,29 +45,34 @@ export const EditDocumentForm = ({
const { toast } = useToast(); const { toast } = useToast();
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<'signers' | 'fields' | 'subject'>('signers'); const [step, setStep] = useState<EditDocumentStep>('signers');
const documentUrl = `data:application/pdf;base64,${document.document}`; const documentUrl = `data:application/pdf;base64,${document.document}`;
const onNextStep = () => { const documentFlow: Record<EditDocumentStep, DocumentFlowStep> = {
if (step === 'signers') { signers: {
setStep('fields'); title: 'Add Signers',
} description: 'Add the people who will sign the document.',
stepIndex: 1,
if (step === 'fields') { onSubmit: () => onAddSignersFormSubmit,
setStep('subject'); },
} fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
onBackStep: () => setStep('signers'),
onSubmit: () => onAddFieldsFormSubmit,
},
subject: {
title: 'Add Subject',
description: 'Add the subject and message you wish to send to signers.',
stepIndex: 3,
onBackStep: () => setStep('fields'),
onSubmit: () => onAddSubjectFormSubmit,
},
}; };
const onPreviousStep = () => { const currentDocumentFlow = documentFlow[step];
if (step === 'fields') {
setStep('signers');
}
if (step === 'subject') {
setStep('fields');
}
};
const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => { const onAddSignersFormSubmit = async (data: TAddSignersFormSchema) => {
try { try {
@@ -72,7 +84,7 @@ export const EditDocumentForm = ({
router.refresh(); router.refresh();
onNextStep(); setStep('fields');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -94,7 +106,7 @@ export const EditDocumentForm = ({
router.refresh(); router.refresh();
onNextStep(); setStep('subject');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -119,8 +131,6 @@ export const EditDocumentForm = ({
}); });
router.refresh(); router.refresh();
onNextStep();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -144,38 +154,43 @@ export const EditDocumentForm = ({
</Card> </Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5"> <div className="col-span-12 lg:col-span-6 xl:col-span-5">
{step === 'signers' && ( <DocumentFlowFormContainer onSubmit={(e) => e.preventDefault()}>
<AddSignersFormPartial <DocumentFlowFormContainerHeader
recipients={recipients} title={currentDocumentFlow.title}
fields={fields} description={currentDocumentFlow.description}
document={document}
onContinue={onNextStep}
onGoBack={onPreviousStep}
onSubmit={onAddSignersFormSubmit}
/> />
)}
{step === 'fields' && ( {step === 'signers' && (
<AddFieldsFormPartial <AddSignersFormPartial
recipients={recipients} documentFlow={documentFlow.signers}
fields={fields} recipients={recipients}
document={document} fields={fields}
onContinue={onNextStep} numberOfSteps={Object.keys(documentFlow).length}
onGoBack={onPreviousStep} onSubmit={onAddSignersFormSubmit}
onSubmit={onAddFieldsFormSubmit} />
/> )}
)}
{step === 'subject' && ( {step === 'fields' && (
<AddSubjectFormPartial <AddFieldsFormPartial
recipients={recipients} documentFlow={documentFlow.fields}
fields={fields} recipients={recipients}
document={document} fields={fields}
onContinue={onNextStep} numberOfSteps={Object.keys(documentFlow).length}
onGoBack={onPreviousStep} onSubmit={onAddFieldsFormSubmit}
onSubmit={onAddSubjectFormSubmit} />
/> )}
)}
{step === 'subject' && (
<AddSubjectFormPartial
documentFlow={documentFlow.subject}
document={document}
recipients={recipients}
fields={fields}
numberOfSteps={Object.keys(documentFlow).length}
onSubmit={onAddSubjectFormSubmit}
/>
)}
</DocumentFlowFormContainer>
</div> </div>
</div> </div>
); );

View File

@@ -9,12 +9,13 @@ export default function Loading() {
<ChevronLeft className="mr-2 inline-block h-5 w-5" /> <ChevronLeft className="mr-2 inline-block h-5 w-5" />
Documents Documents
</Link> </Link>
<h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl"> <h1 className="mt-4 max-w-xs grow-0 truncate text-2xl font-semibold md:text-3xl">
Loading Document... Loading Document...
</h1> </h1>
<div className="mt-8 grid min-h-[80vh] w-full grid-cols-12 gap-x-8"> <div className="mt-8 grid h-[80vh] max-h-[60rem] w-full grid-cols-12 gap-x-8">
<div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7"> <div className="dark:bg-background border-border col-span-12 rounded-xl border-2 bg-white/50 p-2 before:rounded-xl lg:col-span-6 xl:col-span-7">
<div className="flex min-h-[80vh] flex-col items-center justify-center"> <div className="flex h-[80vh] max-h-[60rem] flex-col items-center justify-center">
<Loader className="text-documenso h-12 w-12 animate-spin" /> <Loader className="text-documenso h-12 w-12 animate-spin" />
<p className="text-muted-foreground mt-4">Loading document...</p> <p className="text-muted-foreground mt-4">Loading document...</p>

View File

@@ -7,8 +7,11 @@ import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id'; import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document'; import { getFieldsForDocument } from '@documenso/lib/server-only/field/get-fields-for-document';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document'; import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document'; import { EditDocumentForm } from '~/app/(dashboard)/documents/[id]/edit-document';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentStatus } from '~/components/formatter/document-status'; import { DocumentStatus } from '~/components/formatter/document-status';
export type DocumentPageProps = { export type DocumentPageProps = {
@@ -69,18 +72,28 @@ export default async function DocumentPage({ params }: DocumentPageProps) {
<div className="text-muted-foreground flex items-center"> <div className="text-muted-foreground flex items-center">
<Users2 className="mr-2 h-5 w-5" /> <Users2 className="mr-2 h-5 w-5" />
<span>{recipients.length} Recipient(s)</span> <StackAvatarsWithTooltip recipients={recipients} position="bottom">
<span>{recipients.length} Recipient(s)</span>
</StackAvatarsWithTooltip>
</div> </div>
)} )}
</div> </div>
<EditDocumentForm {document.status !== InternalDocumentStatus.COMPLETED && (
className="mt-8" <EditDocumentForm
document={document} className="mt-8"
user={session} document={document}
recipients={recipients} user={session}
fields={fields} recipients={recipients}
/> fields={fields}
/>
)}
{document.status === InternalDocumentStatus.COMPLETED && (
<div className="mx-auto mt-12 max-w-2xl">
<LazyPDFViewer document={`data:application/pdf;base64,${document.document}`} />
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,65 @@
'use client';
import Link from 'next/link';
import { Edit, Pencil, Share } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern';
import { Document, DocumentStatus, Recipient, SigningStatus, User } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
export type DataTableActionButtonProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
};
export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
const isRecipient = !!recipient;
const isDraft = row.status === DocumentStatus.DRAFT;
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
return match({
isOwner,
isRecipient,
isDraft,
isPending,
isComplete,
isSigned,
})
.with({ isOwner: true, isDraft: true }, () => (
<Button className="w-24" asChild>
<Link href={`/documents/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
</Link>
</Button>
))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-24" asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</Link>
</Button>
))
.otherwise(() => (
<Button className="w-24" disabled>
<Share className="-ml-1 mr-2 h-4 w-4" />
Share
</Button>
));
};

View File

@@ -0,0 +1,133 @@
'use client';
import Link from 'next/link';
import {
Copy,
Download,
Edit,
History,
MoreHorizontal,
Pencil,
Share,
Trash2,
XCircle,
} from 'lucide-react';
import { useSession } from 'next-auth/react';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
export type DataTableActionDropdownProps = {
row: Document & {
User: Pick<User, 'id' | 'name' | 'email'>;
Recipient: Recipient[];
};
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
const recipient = row.Recipient.find((recipient) => recipient.email === session.user.email);
const isOwner = row.User.id === session.user.id;
// const isRecipient = !!recipient;
// const isDraft = row.status === DocumentStatus.DRAFT;
// const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED;
// const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = () => {
let decodedDocument = row.document;
try {
decodedDocument = atob(decodedDocument);
} catch (err) {
// We're just going to ignore this error and try to download the document
console.error(err);
}
const documentBytes = Uint8Array.from(decodedDocument.split('').map((c) => c.charCodeAt(0)));
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = row.title || 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="h-5 w-5 text-gray-500" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!recipient} asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="mr-2 h-4 w-4" />
Sign
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/documents/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled>
<XCircle className="mr-2 h-4 w-4" />
Void
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuItem disabled>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Share className="mr-2 h-4 w-4" />
Share
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -8,7 +8,7 @@ import { Loader } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set'; import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; import { Document, Recipient, User } from '@documenso/prisma/client';
import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
@@ -16,8 +16,16 @@ import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-a
import { DocumentStatus } from '~/components/formatter/document-status'; import { DocumentStatus } from '~/components/formatter/document-status';
import { LocaleDate } from '~/components/formatter/locale-date'; import { LocaleDate } from '~/components/formatter/locale-date';
import { DataTableActionButton } from './data-table-action-button';
import { DataTableActionDropdown } from './data-table-action-dropdown';
export type DocumentsDataTableProps = { export type DocumentsDataTableProps = {
results: FindResultSet<DocumentWithReciepient>; results: FindResultSet<
Document & {
Recipient: Recipient[];
User: Pick<User, 'id' | 'name' | 'email'>;
}
>;
}; };
export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => { export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
@@ -45,7 +53,11 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
{ {
header: 'Title', header: 'Title',
cell: ({ row }) => ( cell: ({ row }) => (
<Link href={`/documents/${row.original.id}`} className="font-medium hover:underline"> <Link
href={`/documents/${row.original.id}`}
title={row.original.title}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.original.title} {row.original.title}
</Link> </Link>
), ),
@@ -67,6 +79,15 @@ export const DocumentsDataTable = ({ results }: DocumentsDataTableProps) => {
accessorKey: 'created', accessorKey: 'created',
cell: ({ row }) => <LocaleDate date={row.getValue('created')} />, cell: ({ row }) => <LocaleDate date={row.getValue('created')} />,
}, },
{
header: 'Actions',
cell: ({ row }) => (
<div className="flex items-center gap-x-4">
<DataTableActionButton row={row.original} />
<DataTableActionDropdown row={row.original} />
</div>
),
},
]} ]}
data={results.data} data={results.data}
perPage={results.perPage} perPage={results.perPage}

View File

@@ -3,8 +3,8 @@ import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats'; import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isDocumentStatus } from '@documenso/lib/types/is-document-status'; import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector'; import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
@@ -16,7 +16,7 @@ import { DocumentsDataTable } from './data-table';
export type DocumentsPageProps = { export type DocumentsPageProps = {
searchParams?: { searchParams?: {
status?: InternalDocumentStatus | 'ALL'; status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue; period?: PeriodSelectorValue;
page?: string; page?: string;
perPage?: string; perPage?: string;
@@ -24,22 +24,20 @@ export type DocumentsPageProps = {
}; };
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) { export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
const session = await getRequiredServerComponentSession(); const user = await getRequiredServerComponentSession();
const stats = await getStats({ const stats = await getStats({
userId: session.id, user,
}); });
const status = isDocumentStatus(searchParams.status) ? searchParams.status : 'ALL'; const status = isExtendedDocumentStatus(searchParams.status) ? searchParams.status : 'ALL';
// const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : ''; // const period = isPeriodSelectorValue(searchParams.period) ? searchParams.period : '';
const page = Number(searchParams.page) || 1; const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20; const perPage = Number(searchParams.perPage) || 20;
const shouldDefaultToPending = status === 'ALL' && stats.PENDING > 0;
const results = await findDocuments({ const results = await findDocuments({
userId: session.id, userId: user.id,
status: status === 'ALL' ? undefined : status, status,
orderBy: { orderBy: {
column: 'created', column: 'created',
direction: 'desc', direction: 'desc',
@@ -57,10 +55,6 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
params.delete('page'); params.delete('page');
} }
if (value === 'ALL') {
params.delete('status');
}
return `/documents?${params.toString()}`; return `/documents?${params.toString()}`;
}; };
@@ -71,41 +65,27 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
<h1 className="mt-12 text-4xl font-semibold">Documents</h1> <h1 className="mt-12 text-4xl font-semibold">Documents</h1>
<div className="mt-8 flex flex-wrap gap-x-4 gap-y-6"> <div className="mt-8 flex flex-wrap gap-x-4 gap-y-6">
<Tabs defaultValue={shouldDefaultToPending ? InternalDocumentStatus.PENDING : status}> <Tabs defaultValue={status} className="overflow-x-auto">
<TabsList> <TabsList>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.PENDING} asChild> {[
<Link href={getTabHref(InternalDocumentStatus.PENDING)}> ExtendedDocumentStatus.INBOX,
<DocumentStatus status={InternalDocumentStatus.PENDING} /> ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />
<span className="ml-1 hidden opacity-50 md:inline-block"> {value !== ExtendedDocumentStatus.ALL && (
{Math.min(stats.PENDING, 99)} <span className="ml-1 hidden opacity-50 md:inline-block">
</span> {Math.min(stats[value], 99)}
</Link> </span>
</TabsTrigger> )}
</Link>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.COMPLETED} asChild> </TabsTrigger>
<Link href={getTabHref(InternalDocumentStatus.COMPLETED)}> ))}
<DocumentStatus status={InternalDocumentStatus.COMPLETED} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.COMPLETED, 99)}
</span>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value={InternalDocumentStatus.DRAFT} asChild>
<Link href={getTabHref(InternalDocumentStatus.DRAFT)}>
<DocumentStatus status={InternalDocumentStatus.DRAFT} />
<span className="ml-1 hidden opacity-50 md:inline-block">
{Math.min(stats.DRAFT, 99)}
</span>
</Link>
</TabsTrigger>
<TabsTrigger className="min-w-[60px]" value="ALL" asChild>
<Link href={getTabHref('ALL')}>All</Link>
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>

View File

@@ -1,8 +1,14 @@
import Link from 'next/link';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
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 { PasswordForm } from '~/components/forms/password'; import { LocaleDate } from '~/components/formatter/locale-date';
import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag'; import { getServerComponentFlag } from '~/helpers/get-server-component-feature-flag';
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
@@ -15,17 +21,55 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile'); redirect('/settings/profile');
} }
let subscription = await getSubscriptionByUserId({ userId: user.id });
// If we don't have a customer record, create one as well as an empty subscription.
if (!subscription?.customerId) {
subscription = await createCustomer({ user });
}
let billingPortalUrl = '';
if (subscription?.customerId) {
billingPortalUrl = await getPortalSession({
customerId: subscription.customerId,
returnUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/settings/billing`,
});
}
return ( return (
<div> <div>
<h3 className="text-lg font-medium">Billing</h3> <h3 className="text-lg font-medium">Billing</h3>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-slate-500">
Here you can update and manage your subscription. 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>
.
</>
)}
</p> </p>
<hr className="my-4" /> <hr className="my-4" />
<PasswordForm user={user} className="max-w-xl" /> {billingPortalUrl && (
<Button asChild>
<Link href={billingPortalUrl}>Manage Subscription</Link>
</Button>
)}
{!billingPortalUrl && (
<p className="max-w-[60ch] text-base text-slate-500">
You do not currently have a customer record, this should not happen. Please contact
support for assistance.
</p>
)}
</div> </div>
); );
} }

View File

@@ -50,7 +50,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
return ( return (
<form <form
className={cn( className={cn(
'dark:bg-background border-border bg-widget sticky top-20 flex h-[calc(100vh-6rem)] max-h-screen flex-col rounded-xl border px-4 py-6', 'dark:bg-background border-border bg-widget sticky top-20 flex h-full max-h-[80rem] flex-col rounded-xl border px-4 py-6',
)} )}
onSubmit={handleSubmit(onFormSubmit)} onSubmit={handleSubmit(onFormSubmit)}
> >
@@ -64,7 +64,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col overflow-y-auto px-2"> <div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4"> <div className="flex flex-1 flex-col gap-y-4">
<div> <div>
<Label htmlFor="full-name">Full Name</Label> <Label htmlFor="full-name">Full Name</Label>
@@ -98,10 +98,10 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex flex-col gap-4 md:flex-row">
<Button <Button
type="button" type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10" className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary" variant="secondary"
size="lg" size="lg"
> >
@@ -109,8 +109,8 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
</Button> </Button>
<Button <Button
className="w-full"
type="submit" type="submit"
className="flex-1"
size="lg" size="lg"
disabled={!isComplete || isSubmitting} disabled={!isComplete || isSubmitting}
> >

View File

@@ -149,7 +149,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
disabled={!localFullName} disabled={!localFullName}
onClick={() => { onClick={() => {
setShowFullNameModal(false); setShowFullNameModal(false);
onSign('local'); void onSign('local');
}} }}
> >
Sign Sign

View File

@@ -57,7 +57,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
</p> </p>
</div> </div>
<div className="mt-8 grid grid-cols-12 gap-8"> <div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card <Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8" className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient gradient

View File

@@ -182,7 +182,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
disabled={!localSignature} disabled={!localSignature}
onClick={() => { onClick={() => {
setShowSignatureModal(false); setShowSignatureModal(false);
onSign('local'); void onSign('local');
}} }}
> >
Sign Sign

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

After

Width:  |  Height:  |  Size: 14 MiB

View File

@@ -46,7 +46,7 @@ export const StackAvatar = ({ first, zIndex, fallbackText, type }: StackAvatarPr
className={` className={`
${zIndexClass} ${zIndexClass}
${firstClass} ${firstClass}
h-10 w-10 border-2 border-solid border-white`} dark:border-border h-10 w-10 border-2 border-solid border-white`}
> >
<AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback> <AvatarFallback className={classes}>{fallbackText ?? 'UK'}</AvatarFallback>
</Avatar> </Avatar>

View File

@@ -11,7 +11,17 @@ import {
import { StackAvatar } from './stack-avatar'; import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars'; import { StackAvatars } from './stack-avatars';
export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[] }) => { export type StackAvatarsWithTooltipProps = {
recipients: Recipient[];
position?: 'top' | 'bottom';
children?: React.ReactNode;
};
export const StackAvatarsWithTooltip = ({
recipients,
position,
children,
}: StackAvatarsWithTooltipProps) => {
const waitingRecipients = recipients.filter( const waitingRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === 'waiting', (recipient) => getRecipientType(recipient) === 'waiting',
); );
@@ -32,9 +42,10 @@ export const StackAvatarsWithTooltip = ({ recipients }: { recipients: Recipient[
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger className="flex cursor-pointer"> <TooltipTrigger className="flex cursor-pointer">
<StackAvatars recipients={recipients} /> {children || <StackAvatars recipients={recipients} />}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>
<TooltipContent side={position}>
<div className="flex flex-col gap-y-5 p-1"> <div className="flex flex-col gap-y-5 p-1">
{completedRecipients.length > 0 && ( {completedRecipients.length > 0 && (
<div> <div>

View File

@@ -7,13 +7,15 @@ import { cn } from '@documenso/ui/lib/utils';
export type DesktopNavProps = HTMLAttributes<HTMLDivElement>; export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
// const pathname = usePathname();
return ( return (
<div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}> <div className={cn('ml-8 hidden flex-1 gap-x-6 md:flex', className)} {...props}>
{/* No Nav tabs while there is only one main page */} {/* We have no other subpaths rn */}
{/* <Link {/* <Link
href="/documents" href="/documents"
className={cn( className={cn(
'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 ', 'text-muted-foreground focus-visible:ring-ring ring-offset-background rounded-md font-medium leading-5 hover:opacity-80 focus-visible:outline-none focus-visible:ring-2',
{ {
'text-foreground': pathname?.startsWith('/documents'), 'text-foreground': pathname?.startsWith('/documents'),
}, },

View File

@@ -4,11 +4,8 @@ import { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Menu } from 'lucide-react';
import { User } from '@documenso/prisma/client'; import { User } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Logo } from '~/components/branding/logo'; import { Logo } from '~/components/branding/logo';
@@ -23,7 +20,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
return ( return (
<header <header
className={cn( className={cn(
'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-40 flex h-16 w-full items-center border-b backdrop-blur', 'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-50 flex h-16 w-full items-center border-b backdrop-blur',
className, className,
)} )}
{...props} {...props}
@@ -41,9 +38,9 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
<div className="flex gap-x-4"> <div className="flex gap-x-4">
<ProfileDropdown user={user} /> <ProfileDropdown user={user} />
<Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden"> {/* <Button variant="outline" size="sm" className="h-10 w-10 p-0.5 md:hidden">
<Menu className="h-6 w-6" /> <Menu className="h-6 w-6" />
</Button> </Button> */}
</div> </div>
</div> </div>
</header> </header>

View File

@@ -118,7 +118,7 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
<DropdownMenuItem <DropdownMenuItem
onSelect={() => onSelect={() =>
signOut({ void signOut({
callbackUrl: '/', callbackUrl: '/',
}) })
} }

View File

@@ -39,7 +39,7 @@ export const PeriodSelector = () => {
params.delete('period'); params.delete('period');
} }
router.push(`${pathname}?${params.toString()}`); router.push(`${pathname}?${params.toString()}`, { scroll: false });
}; };
return ( return (

View File

@@ -63,7 +63,9 @@ export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialog
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => { const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
try { try {
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000)); const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
const [redirectUrl] = await Promise.all([ const [redirectUrl] = await Promise.all([
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }), claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),

View File

@@ -13,7 +13,7 @@ export const PasswordReveal = ({ password }: PasswordRevealProps) => {
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
const onCopyClick = () => { const onCopyClick = () => {
copy(password).then(() => { void copy(password).then(() => {
toast({ toast({
title: 'Copied to clipboard', title: 'Copied to clipboard',
description: 'Your password has been copied to your clipboard.', description: 'Your password has been copied to your clipboard.',

View File

@@ -124,7 +124,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
setValue('signatureDataUrl', draftSignatureDataUrl); setValue('signatureDataUrl', draftSignatureDataUrl);
setValue('signatureText', ''); setValue('signatureText', '');
trigger('signatureDataUrl'); void trigger('signatureDataUrl');
setShowSigningDialog(false); setShowSigningDialog(false);
}; };
@@ -135,7 +135,9 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
signatureText, signatureText,
}: TWidgetFormSchema) => { }: TWidgetFormSchema) => {
try { try {
const delay = new Promise<void>((resolve) => setTimeout(resolve, 1000)); const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
// eslint-disable-next-line turbo/no-undeclared-env-vars // eslint-disable-next-line turbo/no-undeclared-env-vars
const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID; const planId = process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID;

View File

@@ -3,16 +3,17 @@ import { HTMLAttributes } from 'react';
import { CheckCircle2, Clock, File } from 'lucide-react'; import { CheckCircle2, Clock, File } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react'; import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { DocumentStatus as InternalDocumentStatus } from '@documenso/prisma/client'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
type FriendlyStatus = { type FriendlyStatus = {
label: string; label: string;
icon: LucideIcon; icon?: LucideIcon;
color: string; color: string;
}; };
const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = { const FRIENDLY_STATUS_MAP: Record<ExtendedDocumentStatus, FriendlyStatus> = {
PENDING: { PENDING: {
label: 'Pending', label: 'Pending',
icon: Clock, icon: Clock,
@@ -28,10 +29,19 @@ const FRIENDLY_STATUS_MAP: Record<InternalDocumentStatus, FriendlyStatus> = {
icon: File, icon: File,
color: 'text-yellow-500', color: 'text-yellow-500',
}, },
INBOX: {
label: 'Inbox',
icon: SignatureIcon,
color: 'text-muted-foreground',
},
ALL: {
label: 'All',
color: 'text-muted-foreground',
},
}; };
export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & { export type DocumentStatusProps = HTMLAttributes<HTMLSpanElement> & {
status: InternalDocumentStatus; status: ExtendedDocumentStatus;
inheritColor?: boolean; inheritColor?: boolean;
}; };
@@ -45,11 +55,13 @@ export const DocumentStatus = ({
return ( return (
<span className={cn('flex items-center', className)} {...props}> <span className={cn('flex items-center', className)} {...props}>
<Icon {Icon && (
className={cn('mr-2 inline-block h-4 w-4', { <Icon
[color]: !inheritColor, className={cn('mr-2 inline-block h-4 w-4', {
})} [color]: !inheritColor,
/> })}
/>
)}
{label} {label}
</span> </span>
); );

View File

@@ -18,8 +18,8 @@ import { FormErrorMessage } from '../form/form-error-message';
export const ZPasswordFormSchema = z export const ZPasswordFormSchema = z
.object({ .object({
password: z.string().min(6), password: z.string().min(6).max(72),
repeatedPassword: z.string().min(6), repeatedPassword: z.string().min(6).max(72),
}) })
.refine((data) => data.password === data.repeatedPassword, { .refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match', message: 'Passwords do not match',
@@ -92,6 +92,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
<Input <Input
id="password" id="password"
type="password" type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2" className="bg-background mt-2"
{...register('password')} {...register('password')}
/> />
@@ -107,6 +110,9 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
<Input <Input
id="repeated-password" id="repeated-password"
type="password" type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2" className="bg-background mt-2"
{...register('repeatedPassword')} {...register('repeatedPassword')}
/> />

View File

@@ -15,7 +15,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignInFormSchema = z.object({ export const ZSignInFormSchema = z.object({
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(1), password: z.string().min(6).max(72),
}); });
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>; export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
@@ -76,10 +76,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
return ( return (
<form <form
className={cn('flex w-full flex-col gap-y-4', className)} className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={(e) => { onSubmit={handleSubmit(onFormSubmit)}
e.preventDefault();
handleSubmit(onFormSubmit)();
}}
> >
<div> <div>
<Label htmlFor="email" className="text-slate-500"> <Label htmlFor="email" className="text-slate-500">
@@ -99,6 +96,9 @@ export const SignInForm = ({ className }: SignInFormProps) => {
<Input <Input
id="password" id="password"
type="password" type="password"
minLength={6}
maxLength={72}
autoComplete="current-password"
className="bg-background mt-2" className="bg-background mt-2"
{...register('password')} {...register('password')}
/> />

View File

@@ -18,7 +18,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignUpFormSchema = z.object({ export const ZSignUpFormSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
email: z.string().email().min(1), email: z.string().email().min(1),
password: z.string().min(1), password: z.string().min(6).max(72),
}); });
export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>; export type TSignUpFormSchema = z.infer<typeof ZSignUpFormSchema>;
@@ -105,6 +105,9 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
<Input <Input
id="password" id="password"
type="password" type="password"
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2" className="bg-background mt-2"
{...register('password')} {...register('password')}
/> />

View File

@@ -32,7 +32,7 @@ export const getFlag = async (
revalidate: 60, revalidate: 60,
}, },
}) })
.then((res) => res.json()) .then(async (res) => res.json())
.then((res) => ZFeatureFlagValueSchema.parse(res)) .then((res) => ZFeatureFlagValueSchema.parse(res))
.catch(() => false); .catch(() => false);
@@ -64,7 +64,7 @@ export const getAllFlags = async (
revalidate: 60, revalidate: 60,
}, },
}) })
.then((res) => res.json()) .then(async (res) => res.json())
.then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res)) .then((res) => z.record(z.string(), ZFeatureFlagValueSchema).parse(res))
.catch(() => LOCAL_FEATURE_FLAGS); .catch(() => LOCAL_FEATURE_FLAGS);
}; };

View File

@@ -11,6 +11,6 @@ export default function PostHogServerClient() {
return new PostHog(postHogConfig.key, { return new PostHog(postHogConfig.key, {
host: postHogConfig.host, host: postHogConfig.host,
fetch: (...args) => fetch(...args), fetch: async (...args) => fetch(...args),
}); });
} }

View File

@@ -0,0 +1,18 @@
import { useEffect, useState } from 'react';
export function useDebouncedValue<T>(value: T, delay: number) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
export default async function middleware(req: NextRequest) { export default function middleware(req: NextRequest) {
if (req.nextUrl.pathname === '/') { if (req.nextUrl.pathname === '/') {
const redirectUrl = new URL('/documents', req.url); const redirectUrl = new URL('/documents', req.url);

View File

@@ -1,7 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next'; import { NextApiRequest, NextApiResponse } from 'next';
import formidable from 'formidable'; import formidable, { type File } from 'formidable';
import { type File } from 'formidable';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { getServerSession } from '@documenso/lib/next-auth/get-server-session'; import { getServerSession } from '@documenso/lib/next-auth/get-server-session';

View File

@@ -4,7 +4,7 @@ import { appRouter } from '@documenso/trpc/server/router';
export default trpcNext.createNextApiHandler({ export default trpcNext.createNextApiHandler({
router: appRouter, router: appRouter,
createContext: ({ req, res }) => createTrpcContext({ req, res }), createContext: async ({ req, res }) => createTrpcContext({ req, res }),
}); });
// export default async function handler(_req: NextApiRequest, res: NextApiResponse) { // export default async function handler(_req: NextApiRequest, res: NextApiResponse) {

View File

@@ -67,7 +67,7 @@ export function FeatureFlagProvider({
const interval = setInterval(() => { const interval = setInterval(() => {
if (document.hasFocus()) { if (document.hasFocus()) {
getAllFlags().then((newFlags) => setFlags(newFlags)); void getAllFlags().then((newFlags) => setFlags(newFlags));
} }
}, FEATURE_FLAG_POLL_INTERVAL); }, FEATURE_FLAG_POLL_INTERVAL);
@@ -84,7 +84,7 @@ export function FeatureFlagProvider({
return; return;
} }
const onFocus = () => getAllFlags().then((newFlags) => setFlags(newFlags)); const onFocus = () => void getAllFlags().then((newFlags) => setFlags(newFlags));
window.addEventListener('focus', onFocus); window.addEventListener('focus', onFocus);

View File

@@ -7,5 +7,6 @@ module.exports = {
content: [ content: [
...baseConfig.content, ...baseConfig.content,
`${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`, `${path.join(require.resolve('@documenso/ui'), '..')}/**/*.{ts,tsx}`,
`${path.join(require.resolve('@documenso/email'), '..')}/**/*.{ts,tsx}`,
], ],
}; };

1048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,14 +18,14 @@
"@commitlint/config-conventional": "^17.7.0", "@commitlint/config-conventional": "^17.7.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"dotenv-cli": "^7.2.1", "dotenv-cli": "^7.2.1",
"eslint": "^7.32.0", "eslint": "^8.40.0",
"eslint-config-custom": "*", "eslint-config-custom": "*",
"husky": "^8.0.0", "husky": "^8.0.0",
"lint-staged": "^14.0.0", "lint-staged": "^14.0.0",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"turbo": "^1.9.3" "turbo": "^1.9.3"
}, },
"name": "documenso.next", "name": "@documenso/root",
"packageManager": "npm@8.19.2", "packageManager": "npm@8.19.2",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",

40
packages/ee/LICENSE Normal file
View File

@@ -0,0 +1,40 @@
The Documenso Commercial License (the “Commercial License”)
Copyright (c) 2023 Documenso, Inc
With regard to the Documenso Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, an agreement governing
the use of the Software, as mutually agreed by you and Documenso, Inc ("Documenso"),
and otherwise have a valid Documenso Enterprise Edition subscription ("Commercial Subscription").
Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software.
You agree that Documenso and/or its licensors (as applicable) retain all right, title and interest in
and to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Commercial Subscription for the correct number of hosts.
Notwithstanding the foregoing, you may copy and modify the Software for development
and testing purposes, without requiring a subscription. You agree that Documenso and/or
its licensors (as applicable) retain all right, title and interest in and to all such
modifications. You are not granted any other rights beyond what is expressly stated herein.
Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
This Commercial License applies only to the part of this Software that is not distributed under
the AGPLv3 license. Any part of this Software distributed under the MIT license or which
is served client-side as an image, font, cascading stylesheet (CSS), file which produces
or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or
in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall
be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Documenso Software, those
components are licensed under the original license provided by the owner of the
applicable component.

1
packages/ee/index.ts Normal file
View File

@@ -0,0 +1 @@
export {};

17
packages/ee/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@documenso/ee",
"version": "1.0.0",
"main": "./index.ts",
"types": "./index.ts",
"license": "COMMERCIAL",
"files": [
"client-only/",
"server-only/",
"universal/"
],
"scripts": {},
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*"
}
}

View File

@@ -0,0 +1,31 @@
import { stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client';
export type CreateCustomerOptions = {
user: User;
};
export const createCustomer = async ({ user }: CreateCustomerOptions) => {
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
if (existingSubscription) {
throw new Error('User already has a subscription');
}
const customer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
return await prisma.subscription.create({
data: {
userId: user.id,
customerId: customer.id,
},
});
};

View File

@@ -0,0 +1,19 @@
'use server';
import { stripe } from '@documenso/lib/server-only/stripe';
export type GetPortalSessionOptions = {
customerId: string;
returnUrl: string;
};
export const getPortalSession = async ({ customerId, returnUrl }: GetPortalSessionOptions) => {
'use server';
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
return session.url;
};

View File

@@ -0,0 +1,5 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@@ -0,0 +1,71 @@
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
export interface TemplateDocumentCompletedProps {
downloadLink: string;
reviewLink: string;
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentCompleted = ({
downloadLink,
reviewLink,
documentName,
assetBaseUrl,
}: TemplateDocumentCompletedProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Section className="flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
<Img src={getAssetUrl('/static/completed.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
Completed
</Text>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{documentName} was signed by all signers
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by downloading or reviewing the document.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={reviewLink}
>
<Img src={getAssetUrl('/static/review.png')} className="-mb-1 mr-2 inline h-5 w-5" />
Review
</Button>
<Button
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="-mb-1 mr-2 inline h-5 w-5" />
Download
</Button>
</Section>
</Section>
</Tailwind>
);
};
export default TemplateDocumentCompleted;

View File

@@ -0,0 +1,59 @@
import { Button, Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
export interface TemplateDocumentInviteProps {
inviterName: string;
inviterEmail: string;
documentName: string;
signDocumentLink: string;
assetBaseUrl: string;
}
export const TemplateDocumentInvite = ({
inviterName,
documentName,
signDocumentLink,
assetBaseUrl,
}: TemplateDocumentInviteProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Section className="mt-4 flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has invited you to sign "{documentName}"
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by signing the document.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
Sign Document
</Button>
</Section>
</Section>
</Tailwind>
);
};
export default TemplateDocumentInvite;

View File

@@ -0,0 +1,52 @@
import { Img, Section, Tailwind, Text } from '@react-email/components';
import * as config from '@documenso/tailwind-config';
export interface TemplateDocumentPendingProps {
documentName: string;
assetBaseUrl: string;
}
export const TemplateDocumentPending = ({
documentName,
assetBaseUrl,
}: TemplateDocumentPendingProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Tailwind
config={{
theme: {
extend: {
colors: config.theme.extend.colors,
},
},
}}
>
<Section className="flex-row items-center justify-center">
<div className="flex items-center justify-center p-4">
<Img className="h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
</div>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
<Img src={getAssetUrl('/static/clock.png')} className="-mb-0.5 mr-2 inline h-7 w-7" />
Waiting for others
</Text>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{documentName} has been signed
</Text>
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
We're still waiting for other signers to sign this document.
<br />
We'll notify you as soon as it's ready.
</Text>
</Section>
</Tailwind>
);
};
export default TemplateDocumentPending;

View File

@@ -0,0 +1,22 @@
import { Link, Section, Text } from '@react-email/components';
export const TemplateFooter = () => {
return (
<Section>
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
<Text className="my-8 text-sm text-slate-400">
Documenso
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>
</Section>
);
};
export default TemplateFooter;

View File

@@ -1,25 +1,23 @@
import { import {
Body, Body,
Button,
Container, Container,
Head, Head,
Html, Html,
Img, Img,
Link,
Preview, Preview,
Section, Section,
Tailwind, Tailwind,
Text,
} from '@react-email/components'; } from '@react-email/components';
import config from '@documenso/tailwind-config'; import config from '@documenso/tailwind-config';
interface DocumentCompletedEmailTemplateProps { import {
downloadLink?: string; TemplateDocumentCompleted,
reviewLink?: string; TemplateDocumentCompletedProps,
documentName?: string; } from '../template-components/template-document-completed';
assetBaseUrl?: string; import TemplateFooter from '../template-components/template-footer';
}
export type DocumentCompletedEmailTemplateProps = Partial<TemplateDocumentCompletedProps>;
export const DocumentCompletedEmailTemplate = ({ export const DocumentCompletedEmailTemplate = ({
downloadLink = 'https://documenso.com', downloadLink = 'https://documenso.com',
@@ -50,74 +48,23 @@ export const DocumentCompletedEmailTemplate = ({
<Section className="bg-white"> <Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm"> <Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2"> <Section className="p-2">
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" /> <Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<Section className="mt-4 flex-row items-center justify-center"> <TemplateDocumentCompleted
<div className="flex items-center justify-center p-4"> downloadLink={downloadLink}
<Img reviewLink={reviewLink}
className="h-42" documentName={documentName}
src={getAssetUrl('/static/document.png')} assetBaseUrl={assetBaseUrl}
alt="Documenso" />
/>
</div>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-[#7AC455]">
<Img
src={getAssetUrl('/static/completed.png')}
className="-mb-0.5 mr-2 inline h-7 w-7"
/>
Completed
</Text>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{documentName} was signed by all signers
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by downloading or reviewing the document.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="mr-4 inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={reviewLink}
>
<Img
src={getAssetUrl('/static/review.png')}
className="-mb-1 mr-2 inline h-5 w-5"
/>
Review
</Button>
<Button
className="inline-flex items-center justify-center rounded-lg border border-solid border-slate-200 px-4 py-2 text-center text-sm font-medium text-black no-underline"
href={downloadLink}
>
<Img
src={getAssetUrl('/static/download.png')}
className="-mb-1 mr-2 inline h-5 w-5"
/>
Download
</Button>
</Section>
</Section>
</Section> </Section>
</Container> </Container>
<Container className="mx-auto max-w-xl"> <Container className="mx-auto max-w-xl">
<Section> <TemplateFooter />
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
<Text className="my-8 text-sm text-slate-400">
Documenso
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>
</Section>
</Container> </Container>
</Section> </Section>
</Body> </Body>

View File

@@ -1,6 +1,5 @@
import { import {
Body, Body,
Button,
Container, Container,
Head, Head,
Hr, Hr,
@@ -15,13 +14,13 @@ import {
import config from '@documenso/tailwind-config'; import config from '@documenso/tailwind-config';
interface DocumentInviteEmailTemplateProps { import {
inviterName?: string; TemplateDocumentInvite,
inviterEmail?: string; TemplateDocumentInviteProps,
documentName?: string; } from '../template-components/template-document-invite';
signDocumentLink?: string; import TemplateFooter from '../template-components/template-footer';
assetBaseUrl?: string;
} export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps>;
export const DocumentInviteEmailTemplate = ({ export const DocumentInviteEmailTemplate = ({
inviterName = 'Lucas Smith', inviterName = 'Lucas Smith',
@@ -51,36 +50,21 @@ export const DocumentInviteEmailTemplate = ({
> >
<Body className="mx-auto my-auto bg-white font-sans"> <Body className="mx-auto my-auto bg-white font-sans">
<Section> <Section>
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm"> <Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section className="p-2"> <Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" /> <Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<Section className="mt-4 flex-row items-center justify-center"> <TemplateDocumentInvite
<div className="flex items-center justify-center p-4"> inviterName={inviterName}
<Img inviterEmail={inviterEmail}
className="h-42" documentName={documentName}
src={getAssetUrl('/static/document.png')} signDocumentLink={signDocumentLink}
alt="Documenso" assetBaseUrl={assetBaseUrl}
/> />
</div>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has invited you to sign "{documentName}"
</Text>
<Text className="my-1 text-center text-base text-slate-400">
Continue by signing the document.
</Text>
<Section className="mb-6 mt-8 text-center">
<Button
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink}
>
Sign Document
</Button>
</Section>
</Section>
</Section> </Section>
</Container> </Container>
@@ -102,20 +86,7 @@ export const DocumentInviteEmailTemplate = ({
<Hr className="mx-auto mt-12 max-w-xl" /> <Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl"> <Container className="mx-auto max-w-xl">
<Section> <TemplateFooter />
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
<Text className="my-8 text-sm text-slate-400">
Documenso
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>
</Section>
</Container> </Container>
</Section> </Section>
</Body> </Body>

View File

@@ -4,19 +4,20 @@ import {
Head, Head,
Html, Html,
Img, Img,
Link,
Preview, Preview,
Section, Section,
Tailwind, Tailwind,
Text,
} from '@react-email/components'; } from '@react-email/components';
import config from '@documenso/tailwind-config'; import config from '@documenso/tailwind-config';
interface DocumentPendingEmailTemplateProps { import {
documentName?: string; TemplateDocumentPending,
assetBaseUrl?: string; TemplateDocumentPendingProps,
} } from '../template-components/template-document-pending';
import { TemplateFooter } from '../template-components/template-footer';
export type DocumentPendingEmailTemplateProps = Partial<TemplateDocumentPendingProps>;
export const DocumentPendingEmailTemplate = ({ export const DocumentPendingEmailTemplate = ({
documentName = 'Open Source Pledge.pdf', documentName = 'Open Source Pledge.pdf',
@@ -43,55 +44,20 @@ export const DocumentPendingEmailTemplate = ({
> >
<Body className="mx-auto my-auto font-sans"> <Body className="mx-auto my-auto font-sans">
<Section className="bg-white"> <Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm"> <Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-4 backdrop-blur-sm">
<Section className="p-2"> <Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="h-6" /> <Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
<Section className="mt-4 flex-row items-center justify-center"> <TemplateDocumentPending documentName={documentName} assetBaseUrl={assetBaseUrl} />
<div className="flex items-center justify-center p-4">
<Img
className="h-42"
src={getAssetUrl('/static/document.png')}
alt="Documenso"
/>
</div>
<Text className="mb-4 flex items-center justify-center text-center text-base font-semibold text-blue-500">
<Img
src={getAssetUrl('/static/clock.png')}
className="-mb-0.5 mr-2 inline h-7 w-7"
/>
Waiting for others
</Text>
<Text className="text-primary mb-0 text-center text-lg font-semibold">
{documentName} has been signed
</Text>
<Text className="mx-auto mb-6 mt-1 max-w-[80%] text-center text-base text-slate-400">
We're still waiting for other signers to sign this document.
<br />
We'll notify you as soon as it's ready.
</Text>
</Section>
</Section> </Section>
</Container> </Container>
<Container className="mx-auto max-w-xl"> <Container className="mx-auto max-w-xl">
<Section> <TemplateFooter />
<Text className="my-4 text-base text-slate-400">
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documenso.com">
Documenso.
</Link>
</Text>
<Text className="my-8 text-sm text-slate-400">
Documenso
<br />
2261 Market Street, #5211, San Francisco, CA 94114, USA
</Text>
</Section>
</Container> </Container>
</Section> </Section>
</Body> </Body>

View File

@@ -110,9 +110,10 @@ export class MailChannelsTransport implements Transport<SentMessageInfo> {
}); });
} }
res.json().then((data) => { res
return callback(new Error(`MailChannels error: ${data.message}`), null); .json()
}); .then((data) => callback(new Error(`MailChannels error: ${data.message}`), null))
.catch((err) => callback(err, null));
}) })
.catch((err) => { .catch((err) => {
return callback(err, null); return callback(err, null);

View File

@@ -6,9 +6,10 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended', 'plugin:prettier/recommended',
'plugin:package-json/recommended',
], ],
plugins: ['prettier'], plugins: ['prettier', 'package-json'],
env: { env: {
node: true, node: true,
@@ -19,6 +20,8 @@ module.exports = {
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
parserOptions: { parserOptions: {
tsconfigRootDir: __dirname,
project: ['../../apps/*/tsconfig.json', '../../packages/*/tsconfig.json'],
ecmaVersion: 2022, ecmaVersion: 2022,
ecmaFeatures: { ecmaFeatures: {
jsx: true, jsx: true,
@@ -32,6 +35,32 @@ module.exports = {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-duplicate-imports': 'error',
'no-multi-spaces': [
'error',
{
ignoreEOLComments: false,
exceptions: {
BinaryExpression: false,
VariableDeclarator: false,
ImportDeclaration: false,
Property: false,
},
},
],
// Safety with promises so we aren't running with scissors
'no-promise-executor-return': 'error',
'prefer-promise-reject-errors': 'error',
'require-atomic-updates': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': [
'error',
{ checksVoidReturn: { attributes: false } },
],
'@typescript-eslint/promise-function-async': 'error',
'@typescript-eslint/require-await': 'error',
// We never want to use `as` but are required to on occasion to handle // We never want to use `as` but are required to on occasion to handle
// shortcomings in third-party and generated types. // shortcomings in third-party and generated types.
// //

View File

@@ -9,9 +9,10 @@
"eslint": "^8.40.0", "eslint": "^8.40.0",
"eslint-config-next": "13.4.12", "eslint-config-next": "13.4.12",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-config-turbo": "^1.9.3",
"eslint-plugin-package-json": "^0.1.4",
"eslint-plugin-prettier": "^4.2.1", "eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-react": "^7.32.2", "eslint-plugin-react": "^7.32.2",
"eslint-config-turbo": "^1.9.3",
"typescript": "^5.1.6" "typescript": "^5.1.6"
} }
} }

View File

@@ -0,0 +1,9 @@
{
"extends": "@documenso/tsconfig/base.json",
"compilerOptions": {
"allowJs": true,
"noEmit": true,
},
"include": ["**/*.cjs", "**/*.js"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useCallback } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
export const useDocumentElement = () => {
/**
* Given a mouse event, find the nearest element found by the provided selector.
*/
const getPage = (event: MouseEvent, pageSelector: string) => {
if (!(event.target instanceof HTMLElement)) {
return null;
}
const target = event.target;
const $page =
target.closest<HTMLElement>(pageSelector) ?? target.querySelector<HTMLElement>(pageSelector);
if (!$page) {
return null;
}
return $page;
};
/**
* Provided a page and a field, calculate the position of the field
* as a percentage of the page width and height.
*/
const getFieldPosition = (page: HTMLElement, field: HTMLElement) => {
const {
top: pageTop,
left: pageLeft,
height: pageHeight,
width: pageWidth,
} = getBoundingClientRect(page);
const {
top: fieldTop,
left: fieldLeft,
height: fieldHeight,
width: fieldWidth,
} = getBoundingClientRect(field);
return {
x: ((fieldLeft - pageLeft) / pageWidth) * 100,
y: ((fieldTop - pageTop) / pageHeight) * 100,
width: (fieldWidth / pageWidth) * 100,
height: (fieldHeight / pageHeight) * 100,
};
};
/**
* Given a mouse event, determine if the mouse is within the bounds of the
* nearest element found by the provided selector.
*
* @param mouseWidth The artifical width of the mouse.
* @param mouseHeight The artifical height of the mouse.
*/
const isWithinPageBounds = useCallback(
(event: MouseEvent, pageSelector: string, mouseWidth = 0, mouseHeight = 0) => {
const $page = getPage(event, pageSelector);
if (!$page) {
return false;
}
const { top, left, height, width } = $page.getBoundingClientRect();
const halfMouseWidth = mouseWidth / 2;
const halfMouseHeight = mouseHeight / 2;
if (event.clientY > top + height - halfMouseHeight || event.clientY < top + halfMouseHeight) {
return false;
}
if (event.clientX > left + width - halfMouseWidth || event.clientX < left + halfMouseWidth) {
return false;
}
return true;
},
[],
);
return {
getPage,
getFieldPosition,
isWithinPageBounds,
};
};

View File

@@ -7,6 +7,7 @@ import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { getUserByEmail } from '../server-only/user/get-user-by-email'; import { getUserByEmail } from '../server-only/user/get-user-by-email';
import { ErrorCodes } from './error-codes';
export const NEXT_AUTH_OPTIONS: AuthOptions = { export const NEXT_AUTH_OPTIONS: AuthOptions = {
adapter: PrismaAdapter(prisma), adapter: PrismaAdapter(prisma),
@@ -23,21 +24,23 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}, },
authorize: async (credentials, _req) => { authorize: async (credentials, _req) => {
if (!credentials) { if (!credentials) {
return null; throw new Error(ErrorCodes.CredentialsNotFound);
} }
const { email, password } = credentials; const { email, password } = credentials;
const user = await getUserByEmail({ email }).catch(() => null); const user = await getUserByEmail({ email }).catch(() => {
throw new Error(ErrorCodes.IncorrectEmailPassword);
});
if (!user || !user.password) { if (!user.password) {
return null; throw new Error(ErrorCodes.UserMissingPassword);
} }
const isPasswordsSame = compare(password, user.password); const isPasswordsSame = await compare(password, user.password);
if (!isPasswordsSame) { if (!isPasswordsSame) {
return null; throw new Error(ErrorCodes.IncorrectEmailPassword);
} }
return { return {
@@ -86,7 +89,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
}; };
}, },
async session({ token, session }) { session({ token, session }) {
if (token && token.email) { if (token && token.email) {
return { return {
...session, ...session,

View File

@@ -0,0 +1,5 @@
export const ErrorCodes = {
IncorrectEmailPassword: 'incorrect-email-password',
UserMissingPassword: 'missing-password',
CredentialsNotFound: 'credentials-not-found',
} as const;

View File

@@ -1,9 +1,6 @@
import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next'; import { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from 'next';
import { headers } from 'next/headers';
import { NextRequest } from 'next/server';
import { getServerSession as getNextAuthServerSession } from 'next-auth'; import { getServerSession as getNextAuthServerSession } from 'next-auth';
import { getToken } from 'next-auth/jwt';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
@@ -30,18 +27,6 @@ export const getServerSession = async ({ req, res }: GetServerSessionOptions) =>
return user; return user;
}; };
export const getServerComponentToken = async () => {
const requestHeaders = Object.fromEntries(headers().entries());
const req = new NextRequest('http://example.com', {
headers: requestHeaders,
});
const token = await getToken({
req,
});
};
export const getServerComponentSession = async () => { export const getServerComponentSession = async () => {
const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS); const session = await getNextAuthServerSession(NEXT_AUTH_OPTIONS);

View File

@@ -87,6 +87,6 @@ export const completeDocumentWithToken = async ({
if (documents.count > 0) { if (documents.count > 0) {
console.log('sealing document'); console.log('sealing document');
sealDocument({ documentId: document.id }); await sealDocument({ documentId: document.id });
} }
}; };

View File

@@ -1,13 +1,15 @@
import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { Document, DocumentStatus, Prisma } from '@documenso/prisma/client'; import { Document, Prisma, SigningStatus } from '@documenso/prisma/client';
import { DocumentWithReciepient } from '@documenso/prisma/types/document-with-recipient'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { FindResultSet } from '../../types/find-result-set'; import { FindResultSet } from '../../types/find-result-set';
export interface FindDocumentsOptions { export interface FindDocumentsOptions {
userId: number; userId: number;
term?: string; term?: string;
status?: DocumentStatus; status?: ExtendedDocumentStatus;
page?: number; page?: number;
perPage?: number; perPage?: number;
orderBy?: { orderBy?: {
@@ -19,29 +21,102 @@ export interface FindDocumentsOptions {
export const findDocuments = async ({ export const findDocuments = async ({
userId, userId,
term, term,
status, status = ExtendedDocumentStatus.ALL,
page = 1, page = 1,
perPage = 10, perPage = 10,
orderBy, orderBy,
}: FindDocumentsOptions): Promise<FindResultSet<DocumentWithReciepient>> => { }: FindDocumentsOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const orderByColumn = orderBy?.column ?? 'created'; const orderByColumn = orderBy?.column ?? 'created';
const orderByDirection = orderBy?.direction ?? 'desc'; const orderByDirection = orderBy?.direction ?? 'desc';
const filters: Prisma.DocumentWhereInput = { const termFilters = !term
status, ? undefined
userId, : ({
}; title: {
contains: term,
mode: 'insensitive',
},
} as const);
if (term) { const filters = match<ExtendedDocumentStatus, Prisma.DocumentWhereInput>(status)
filters.title = { .with(ExtendedDocumentStatus.ALL, () => ({
contains: term, OR: [
mode: 'insensitive', {
}; userId,
} },
{
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: user.email,
},
},
},
],
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
},
},
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
userId,
status: ExtendedDocumentStatus.DRAFT,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
OR: [
{
userId,
status: ExtendedDocumentStatus.PENDING,
},
{
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
},
},
},
],
}))
.with(ExtendedDocumentStatus.COMPLETED, () => ({
OR: [
{
userId,
status: ExtendedDocumentStatus.COMPLETED,
},
{
status: ExtendedDocumentStatus.COMPLETED,
Recipient: {
some: {
email: user.email,
},
},
},
],
}))
.exhaustive();
const [data, count] = await Promise.all([ const [data, count] = await Promise.all([
prisma.document.findMany({ prisma.document.findMany({
where: { where: {
...termFilters,
...filters, ...filters,
}, },
skip: Math.max(page - 1, 0) * perPage, skip: Math.max(page - 1, 0) * perPage,
@@ -50,21 +125,37 @@ export const findDocuments = async ({
[orderByColumn]: orderByDirection, [orderByColumn]: orderByDirection,
}, },
include: { include: {
User: {
select: {
id: true,
name: true,
email: true,
},
},
Recipient: true, Recipient: true,
}, },
}), }),
prisma.document.count({ prisma.document.count({
where: { where: {
...termFilters,
...filters, ...filters,
}, },
}), }),
]); ]);
const maskedData = data.map((doc) => ({
...doc,
Recipient: doc.Recipient.map((recipient) => ({
...recipient,
token: recipient.email === user.email ? recipient.token : '',
})),
}));
return { return {
data, data: maskedData,
count, count,
currentPage: Math.max(page, 1), currentPage: Math.max(page, 1),
perPage, perPage,
totalPages: Math.ceil(count / perPage), totalPages: Math.ceil(count / perPage),
}; } satisfies FindResultSet<typeof maskedData>;
}; };

View File

@@ -1,30 +1,88 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@documenso/prisma/client'; import { SigningStatus, User } from '@documenso/prisma/client';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type GetStatsInput = { export type GetStatsInput = {
userId: number; user: User;
}; };
export const getStats = async ({ userId }: GetStatsInput) => { export const getStats = async ({ user }: GetStatsInput) => {
const result = await prisma.document.groupBy({ const [ownerCounts, notSignedCounts, hasSignedCounts] = await Promise.all([
by: ['status'], prisma.document.groupBy({
_count: { by: ['status'],
_all: true, _count: {
}, _all: true,
where: { },
userId, where: {
}, userId: user.id,
}); },
}),
prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
status: ExtendedDocumentStatus.PENDING,
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.NOT_SIGNED,
},
},
},
}),
prisma.document.groupBy({
by: ['status'],
_count: {
_all: true,
},
where: {
status: {
not: ExtendedDocumentStatus.DRAFT,
},
Recipient: {
some: {
email: user.email,
signingStatus: SigningStatus.SIGNED,
},
},
},
}),
]);
const stats: Record<DocumentStatus, number> = { const stats: Record<ExtendedDocumentStatus, number> = {
[DocumentStatus.DRAFT]: 0, [ExtendedDocumentStatus.DRAFT]: 0,
[DocumentStatus.PENDING]: 0, [ExtendedDocumentStatus.PENDING]: 0,
[DocumentStatus.COMPLETED]: 0, [ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
}; };
result.forEach((stat) => { ownerCounts.forEach((stat) => {
stats[stat.status] = stat._count._all; stats[stat.status] = stat._count._all;
}); });
notSignedCounts.forEach((stat) => {
stats[ExtendedDocumentStatus.INBOX] += stat._count._all;
});
hasSignedCounts.forEach((stat) => {
if (stat.status === ExtendedDocumentStatus.COMPLETED) {
stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all;
}
if (stat.status === ExtendedDocumentStatus.PENDING) {
stats[ExtendedDocumentStatus.PENDING] += stat._count._all;
}
});
Object.keys(stats).forEach((key) => {
if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) {
stats[ExtendedDocumentStatus.ALL] += stats[key];
}
});
return stats; return stats;
}; };

View File

@@ -59,7 +59,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
signDocumentLink, signDocumentLink,
}); });
mailer.sendMail({ await mailer.sendMail({
to: { to: {
address: email, address: email,
name, name,

View File

@@ -0,0 +1,15 @@
'use server';
import { prisma } from '@documenso/prisma';
export type GetSubscriptionByUserIdOptions = {
userId: number;
};
export const getSubscriptionByUserId = ({ userId }: GetSubscriptionByUserIdOptions) => {
return prisma.subscription.findFirst({
where: {
userId,
},
});
};

View File

@@ -1,5 +1,5 @@
export type FindResultSet<T> = { export type FindResultSet<T> = {
data: T[]; data: T extends Array<any> ? T : T[];
count: number; count: number;
currentPage: number; currentPage: number;
perPage: number; perPage: number;

View File

@@ -0,0 +1,11 @@
import { ExtendedDocumentStatus } from '../types/extended-document-status';
export const isExtendedDocumentStatus = (value: unknown): value is ExtendedDocumentStatus => {
if (typeof value !== 'string') {
return false;
}
// We're using the assertion for a type-guard so it's safe to ignore the eslint warning
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return Object.values(ExtendedDocumentStatus).includes(value as ExtendedDocumentStatus);
};

View File

@@ -1,11 +1,11 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["extendedWhereUnique", "jsonProtocol"]
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("NEXT_PRIVATE_DATABASE_URL") url = env("NEXT_PRIVATE_DATABASE_URL")
directUrl = env("NEXT_PRIVATE_DIRECT_DATABASE_URL")
} }
enum IdentityProvider { enum IdentityProvider {

View File

@@ -0,0 +1,5 @@
{
"extends": "@documenso/tsconfig/react-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View File

@@ -1,5 +1,5 @@
import { Document, Recipient } from '@documenso/prisma/client'; import { Document, Recipient } from '@documenso/prisma/client';
export type DocumentWithReciepient = Document & { export type DocumentWithRecipient = Document & {
Recipient: Recipient[]; Recipient: Recipient[];
}; };

View File

@@ -0,0 +1,12 @@
import { Document, Recipient } from '@documenso/prisma/client';
export type DocumentWithRecipientAndSender = Omit<Document, 'document'> & {
recipient: Recipient;
sender: {
id: number;
name: string | null;
email: string;
};
subject: string;
description: string;
};

View File

@@ -0,0 +1,10 @@
import { DocumentStatus } from '@prisma/client';
export const ExtendedDocumentStatus = {
...DocumentStatus,
INBOX: 'INBOX',
ALL: 'ALL',
} as const;
export type ExtendedDocumentStatus =
(typeof ExtendedDocumentStatus)[keyof typeof ExtendedDocumentStatus];

View File

@@ -115,6 +115,11 @@ module.exports = {
'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out',
}, },
screens: {
'3xl': '1920px',
'4xl': '2560px',
'5xl': '3840px',
},
}, },
}, },
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')], plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],

View File

@@ -5,7 +5,6 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"eslint": "7.32.0",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"tailwindcss": "^3.2.7", "tailwindcss": "^3.2.7",
"tailwindcss-animate": "^1.0.5" "tailwindcss-animate": "^1.0.5"

Some files were not shown because too many files have changed in this diff Show More