Compare commits

..

33 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan
599dc9bd98 chore: add more to avoid breaking 2023-11-25 09:55:05 +00:00
Ephraim Atta-Duncan
f270ea4b8e feat: add millionjs to marketing page 2023-11-25 09:28:13 +00:00
Mythie
8048c29480 fix: override @react-email/tailwind to avoid perf regression 2023-11-24 23:57:34 +11:00
Mythie
84b958d5b7 fix: universal upload hitting cache 2023-11-24 20:06:47 +11:00
Mythie
d8688692f7 fix: move singleplayer create to trpc 2023-11-24 16:58:18 +11:00
Mythie
8230349114 fix: unable to load font for signing 2023-11-24 16:17:54 +11:00
Mythie
c054fc78a4 fix: resolve issues with emailVerified jwt property 2023-11-23 15:11:37 +11:00
Mythie
5de0c464f0 fix: hydration errors for modifier key 2023-11-23 13:57:08 +11:00
Lucas Smith
9444e0cc67 fix: docker build requires smtp host (#672)
set a default for smtp host and add an action
for testing docker builds on each pull request
2023-11-22 16:26:39 +11:00
Lucas Smith
be0fe079a3 fix: add healthcheck endpoint (#671) 2023-11-22 15:46:21 +11:00
Catalin Pit
fbbc3b89c3 feat: email verification for registration (#599) 2023-11-21 15:44:04 +11:00
Bilal Ahmad Bhat
6c73453542 #666 feat: disabled resend button for recipients (#667) 2023-11-20 21:53:57 +11:00
Anik Dhabal Babu
17eeaa2d25 fix: improve the validation message for documenso app (#640)
* fix: improve the validation message

* fix: improve the validation message

---------

Co-authored-by: Catalin Pit <catalinpit@gmail.com>
2023-11-20 12:23:27 +02:00
Catalin Pit
a8d49bb8b8 chore: added Documenso video walkthrough (#665) 2023-11-20 19:01:34 +11:00
Anupam
e077c36fe4 fix: added the zod validation msg in the single player mode (#646) 2023-11-17 14:28:42 +02:00
Adithya Krishna
7ce4cf8381 feat: add dark mode toggle (#529) 2023-11-17 17:01:39 +11:00
Ephraim Atta-Duncan
cebdf5fd8e chore: custom command menu shortcut text for macOS (#657) 2023-11-17 16:47:19 +11:00
Ephraim Atta-Duncan
8adc44802f feat: copy signing link from avatar stack (#658) 2023-11-17 16:12:47 +11:00
Ephraim Atta-Duncan
06714a2aeb chore: append _signed to files when downloading (#656) 2023-11-17 12:02:22 +11:00
Mythie
1c9cec1e93 fix: remove plausible provider 2023-11-17 10:55:09 +11:00
Timur Ercan
e838a07bf9 chore: remove share button from top level, texts (#653) 2023-11-16 16:43:50 +11:00
Lucas Smith
8722e4de74 chore: marketing site updates (#654)
Change "Open" links to "Open Startup" to reduce confusion about how to open the web app.

Additionally add the OSS Friends link to the footer since it was dropped during some changes.
2023-11-16 13:39:22 +11:00
Nafees Nazik
f7d8ebb9de feat: enable resend email menu (#496) 2023-11-16 13:08:31 +11:00
Ephraim Atta-Duncan
67f3b2de45 fix: fetch the correct number of open issues using the github search api (#495) 2023-11-15 21:56:09 +11:00
Tanay
0da080aa41 fix: hovering on tabslist items at dashboard (#645) 2023-11-15 18:18:11 +11:00
Nafees Nazik
fe25239a4e feat: cache getServerComponentSession calls (#644) 2023-11-15 17:42:27 +11:00
SAHIL SIRAJALI KAZI
5ea4a16e36 fix: safari pdf overflow issue fixed (#466) 2023-11-15 17:35:57 +11:00
Nafees Nazik
5002a475d1 feat: limit document upload size (#347) 2023-11-15 13:13:31 +11:00
Nafees Nazik
ca9c0d7bf0 chore: add some eslint rules (#344) 2023-11-15 13:10:17 +11:00
Ephraim Atta-Duncan
3f0341c7d4 feat: add dialog to confirm signing (#342) 2023-11-14 19:01:00 +11:00
Lucas Smith
c3fe98b05f fix: update docker compose entrypoint (#650) 2023-11-14 13:11:57 +11:00
neo773
608a4eaaa6 feat: refactor og image generation (#639) 2023-11-14 13:08:14 +11:00
Nafees Nazik
d6ae0b44e6 feat: use nextjs.js standalone output for improvised docker image (#338) 2023-11-12 13:15:42 +11:00
197 changed files with 60323 additions and 1692 deletions

View File

@@ -14,8 +14,8 @@ env:
HUSKY: 0
jobs:
build:
name: Build
build_app:
name: Build App
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -37,3 +37,16 @@ jobs:
- name: Build
run: npm run build
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 2
- name: Build Docker Image
run: ./docker/build.sh

16
.prettierignore Normal file
View File

@@ -0,0 +1,16 @@
node_modules
.next
public
**/**/node_modules
**/**/.next
**/**/public
*.lock
*.log
*.test.ts
.gitignore
.npmignore
.prettierignore
.DS_Store
.eslintignore

View File

@@ -193,6 +193,12 @@ git clone https://github.com/documenso/documenso
We support DevContainers for VSCode. [Click here to get started.](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/documenso/documenso)
### Video walkthrough
If you're a visual learner and prefer to watch a video walkthrough of setting up Documenso locally, check out this video:
[![Watch the video](https://img.youtube.com/vi/Y0ppIQrEnZs/hqdefault.jpg)](https://youtu.be/Y0ppIQrEnZs)
## Docker
🚧 Docker containers and images are current in progress. We are actively working on bringing a simple Docker build and publish pipeline for Documenso.

View File

@@ -14,9 +14,11 @@ tags:
Today I'm happy to announce that we closed a \$1.25M Pre-Seed round for Documenso, bringing our total funding to \$1.54M. The round actually closed last month, we just were sneaky about it.
## Two more for the road (to open signing)
We're ecstatic to welcome [OSS Capital](https://twitter.com/osscapital) and especially [Joseph Jacks](https://twitter.com/JosephJacks_) to the inner circle of the open signing revolution. We're also fortunate to be joined by Orrick's very own [John Harrison](https://www.linkedin.com/in/john-harrison-a1213b9/) and his legal experience. For those who are wondering, yes, the round was, of course, signed using Documenso.
## Open Source, Open Metrics
If you follow us, you know we're firmly committed to the open source values of openness and transparency. For us, this includes not only the code side of things but also the business. As we aim to build trust among our investors, customers, and partners, we want to be open about what's going on. We also want to allow everyone to learn from our data and choices, just as we did from so many other COSS (Commercial Open Source) startups. The term "Open Startup" isn't precisely defined (and probably will never be, just like startup). There is however a [great write-up](https://cal.com/blog/open-startup) about the basics by the founder of our favorite open source scheduling tool Cal.com.
The two main takeaways are:

View File

@@ -24,9 +24,9 @@ Were an open-source project and focus on building a great developer experienc
So, were switching all conversations, team and community-wide, to Discord.
In this post, we wont debate *why* were switching — Slack vs. Discord is a long-lasting debate with pros and cons, and fans on both sides. There are great [stories](https://blog.meilisearch.com/from-slack-to-discord-our-migration/) and [threads](https://twitter.com/McPizza0/status/1655519558600470528) on the topic. We just dont want to write yet another story here.
In this post, we wont debate _why_ were switching — Slack vs. Discord is a long-lasting debate with pros and cons, and fans on both sides. There are great [stories](https://blog.meilisearch.com/from-slack-to-discord-our-migration/) and [threads](https://twitter.com/McPizza0/status/1655519558600470528) on the topic. We just dont want to write yet another story here.
Instead, well focus on *how* we plan to make the switch.
Instead, well focus on _how_ we plan to make the switch.
## Who is this story for?
@@ -47,14 +47,13 @@ The detailed plan goes like this:
- 2023-08-02 `t+8`: We announce to the community the upcoming changes in the different channels — GitHub, Twitter, and Slack.
- **GitHub**
- Create new Pull Request
- Add story to the blog
- Update link to the community
```
https://documen.so/discord
```
- Start a new Discussion
```markdown
@@ -73,6 +72,7 @@ The detailed plan goes like this:
```
- **Twitter**
- [Tweet the announcement](https://twitter.com/documenso/status/1686719482096766977)
- Pin Tweet
- Update link in bio
@@ -87,6 +87,7 @@ The detailed plan goes like this:
```
- **Slack**
- Post message in `#general`
```markdown
@@ -106,15 +107,15 @@ The detailed plan goes like this:
- Pin post
- Set topic and description
```
We're switching to Discord. Join the fun: https://documen.so/discord
```
- Archive channels: `#code-review` `#how-to` `#meet-and-greet` `#random-memes` `#self-hosting` `#support`
- 2023-08-09 `t+15`: 7 days later, we send a reminder on Slack.
- **Slack**
- Schedule reminder in `#general`
```
@@ -131,5 +132,5 @@ The detailed plan goes like this:
## Final thoughts
- Were at the very, early stage on our journey to building a beautiful, open-source DocuSign alternative. We want to build a great developer experience with the open-source community and, switching to Discord, we want to set up the foundations of an open, safe place for community members to get in touch, brainstorm ideas, and have fun.
- It doesnt mean we wont ever switch back to Slack. The tools of today arent the ones of tomorrow. We dont delete the Slack workspace, we archive it, and keep the `documenso` handle. May it be just an *au revoir?*
- It doesnt mean we wont ever switch back to Slack. The tools of today arent the ones of tomorrow. We dont delete the Slack workspace, we archive it, and keep the `documenso` handle. May it be just an _au revoir?_
- For now, were pushing forward and are eager to welcome you on Discord. Make sure to [join the server](https://documen.so/discord) in order to keep up to date on all things Documenso. See you there!

View File

@@ -1,24 +1,46 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require('path');
const { withContentlayer } = require('next-contentlayer');
import dotenv from 'dotenv';
import fs from 'fs';
import million from 'million/compiler';
import { withContentlayer } from 'next-contentlayer';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// eslint-disable-next-line turbo/no-undeclared-env-vars
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
ENV_FILES.forEach((file) => {
require('dotenv').config({
dotenv.config({
path: path.join(__dirname, `../../${file}`),
});
});
// !: This is a temp hack to get caveat working without placing it back in the public directory.
// !: By inlining this at build time we should be able to sign faster.
const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
experimental: {
serverActionsBodySizeLimit: '10mb',
outputFileTracingRoot: path.join(__dirname, '../../'),
},
reactStrictMode: true,
transpilePackages: ['@documenso/lib', '@documenso/prisma', '@documenso/trpc', '@documenso/ui'],
transpilePackages: [
'@documenso/assets',
'@documenso/lib',
'@documenso/tailwind-config',
'@documenso/trpc',
'@documenso/ui',
],
env: {
NEXT_PUBLIC_PROJECT: 'marketing',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {
@@ -33,7 +55,7 @@ const config = {
return config;
},
async headers() {
headers() {
return [
{
source: '/:path*',
@@ -67,7 +89,7 @@ const config = {
},
];
},
async rewrites() {
rewrites() {
return [
{
source: '/ingest/:path*',
@@ -77,4 +99,8 @@ const config = {
},
};
module.exports = withContentlayer(config);
export default million.next(withContentlayer(config), {
auto: {
rsc: true,
},
});

View File

@@ -8,10 +8,12 @@
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/assets": "*",
"@documenso/lib": "*",
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
@@ -22,6 +24,7 @@
"lucide-react": "^0.279.0",
"luxon": "^3.4.0",
"micro": "^10.0.1",
"million": "^2.6.4",
"next": "14.0.0",
"next-auth": "4.24.3",
"next-contentlayer": "^0.3.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -4,13 +4,13 @@ import { allBlogPosts } from 'contentlayer/generated';
export const runtime = 'edge';
export const size = {
export const contentType = 'image/png';
export const IMAGE_SIZE = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
type BlogPostOpenGraphImageProps = {
params: { post: string };
};
@@ -25,16 +25,16 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
// The long urls are needed for a compiler optimisation on the Next.js side, lifting this up
// to a constant will break og image generation.
const [interBold, interRegular, backgroundImage, logoImage] = await Promise.all([
fetch(new URL('./../../../../assets/inter-bold.ttf', import.meta.url)).then(async (res) =>
fetch(new URL('@documenso/assets/fonts/inter-bold.ttf', import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL('./../../../../assets/inter-regular.ttf', import.meta.url)).then(async (res) =>
fetch(new URL('@documenso/assets/fonts/inter-regular.ttf', import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL('./../../../../assets/background-blog-og.png', import.meta.url)).then(
fetch(new URL('@documenso/assets/images/background-blog-og.png', import.meta.url)).then(
async (res) => res.arrayBuffer(),
),
fetch(new URL('./../../../../../public/logo.png', import.meta.url)).then(async (res) =>
fetch(new URL('@documenso/assets/logo.png', import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
]);
@@ -56,7 +56,7 @@ export default async function BlogPostOpenGraphImage({ params }: BlogPostOpenGra
</div>
),
{
...size,
...IMAGE_SIZE,
fonts: [
{
name: 'Inter',

View File

@@ -18,6 +18,14 @@ export const revalidate = 3600;
export const dynamic = 'force-dynamic';
const GITHUB_HEADERS: Record<string, string> = {
accept: 'application/vnd.github.v3+json',
};
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
}
const ZGithubStatsResponse = z.object({
stargazers_count: z.number(),
forks_count: z.number(),
@@ -28,6 +36,10 @@ const ZMergedPullRequestsResponse = z.object({
total_count: z.number(),
});
const ZOpenIssuesResponse = z.object({
total_count: z.number(),
});
const ZStargazersLiveResponse = z.record(
z.object({
stars: z.number(),
@@ -48,49 +60,76 @@ const ZEarlyAdoptersResponse = z.record(
export type StargazersType = z.infer<typeof ZStargazersLiveResponse>;
export type EarlyAdoptersType = z.infer<typeof ZEarlyAdoptersResponse>;
export default async function OpenPage() {
const GITHUB_HEADERS: Record<string, string> = {
accept: 'application/vnd.github.v3+json',
};
if (process.env.NEXT_PRIVATE_GITHUB_TOKEN) {
GITHUB_HEADERS.authorization = `Bearer ${process.env.NEXT_PRIVATE_GITHUB_TOKEN}`;
}
const {
forks_count: forksCount,
open_issues: openIssues,
stargazers_count: stargazersCount,
} = await fetch('https://api.github.com/repos/documenso/documenso', {
headers: GITHUB_HEADERS,
const fetchGithubStats = async () => {
return await fetch('https://api.github.com/repos/documenso/documenso', {
headers: {
...GITHUB_HEADERS,
},
})
.then(async (res) => res.json())
.then((res) => ZGithubStatsResponse.parse(res));
};
const { total_count: mergedPullRequests } = await fetch(
const fetchOpenIssues = async () => {
return await fetch(
'https://api.github.com/search/issues?q=repo:documenso/documenso+type:issue+state:open&page=0&per_page=1',
{
headers: {
...GITHUB_HEADERS,
},
},
)
.then(async (res) => res.json())
.then((res) => ZOpenIssuesResponse.parse(res));
};
const fetchMergedPullRequests = async () => {
return await fetch(
'https://api.github.com/search/issues?q=repo:documenso/documenso/+is:pr+merged:>=2010-01-01&page=0&per_page=1',
{
headers: GITHUB_HEADERS,
headers: {
...GITHUB_HEADERS,
},
},
)
.then(async (res) => res.json())
.then((res) => ZMergedPullRequestsResponse.parse(res));
};
const STARGAZERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats', {
const fetchStargazers = async () => {
return await fetch('https://stargrazer-live.onrender.com/api/stats', {
headers: {
accept: 'application/json',
},
})
.then(async (res) => res.json())
.then((res) => ZStargazersLiveResponse.parse(res));
};
const EARLY_ADOPTERS_DATA = await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
const fetchEarlyAdopters = async () => {
return await fetch('https://stargrazer-live.onrender.com/api/stats/stripe', {
headers: {
accept: 'application/json',
},
})
.then(async (res) => res.json())
.then((res) => ZEarlyAdoptersResponse.parse(res));
};
export default async function OpenPage() {
const [
{ forks_count: forksCount, stargazers_count: stargazersCount },
{ total_count: openIssues },
{ total_count: mergedPullRequests },
STARGAZERS_DATA,
EARLY_ADOPTERS_DATA,
] = await Promise.all([
fetchGithubStats(),
fetchOpenIssues(),
fetchMergedPullRequests(),
fetchStargazers(),
fetchEarlyAdopters(),
]);
const MONTHLY_USERS = await getUserMonthlyGrowth();

View File

@@ -2,7 +2,7 @@ import Image from 'next/image';
import { z } from 'zod';
import backgroundPattern from '~/assets/background-pattern.png';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { OSSFriendsContainer } from './container';
import { TOSSFriendsSchema, ZOSSFriendsSchema } from './schema';

View File

@@ -8,23 +8,23 @@ import { useRouter } from 'next/navigation';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { DocumentDataType, Field, Prisma, Recipient } from '@documenso/prisma/client';
import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentDataType, Prisma } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { AddFieldsFormPartial } from '@documenso/ui/primitives/document-flow/add-fields';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import { AddSignatureFormPartial } from '@documenso/ui/primitives/document-flow/add-signature';
import { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import type { TAddSignatureFormSchema } from '@documenso/ui/primitives/document-flow/add-signature.types';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { createSinglePlayerDocument } from '~/components/(marketing)/single-player-mode/create-single-player-document.action';
type SinglePlayerModeStep = 'fields' | 'sign';
// !: This entire file is a hack to get around failed prerendering of
@@ -41,6 +41,9 @@ export const SinglePlayerClient = () => {
const [step, setStep] = useState<SinglePlayerModeStep>('fields');
const [fields, setFields] = useState<Field[]>([]);
const { mutateAsync: createSinglePlayerDocument } =
trpc.singleplayer.createSinglePlayerDocument.useMutation();
const documentFlow: Record<SinglePlayerModeStep, DocumentFlowStep> = {
fields: {
title: 'Add document',

View File

@@ -7,11 +7,10 @@ import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
export default function NotFound() {
const router = useRouter();

View File

@@ -2,14 +2,13 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardBeautifulFigure from '@documenso/assets/images/card-beautiful-figure.png';
import cardFastFigure from '@documenso/assets/images/card-fast-figure.png';
import cardSmartFigure from '@documenso/assets/images/card-smart-figure.png';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardBeautifulFigure from '~/assets/card-beautiful-figure.png';
import cardFastFigure from '~/assets/card-fast-figure.png';
import cardSmartFigure from '~/assets/card-smart-figure.png';
export type FasterSmarterBeautifulBentoProps = HTMLAttributes<HTMLDivElement>;
export const FasterSmarterBeautifulBento = ({

View File

@@ -1,17 +1,17 @@
'use client';
import { HTMLAttributes } from 'react';
import type { HTMLAttributes } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { cn } from '@documenso/ui/lib/utils';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
export type FooterProps = HTMLAttributes<HTMLDivElement>;
@@ -26,23 +26,22 @@ const FOOTER_LINKS = [
{ href: '/singleplayer', text: 'Singleplayer' },
{ href: '/blog', text: 'Blog' },
{ href: '/design-system', text: 'Design' },
{ href: '/open', text: 'Open' },
{ href: '/open', text: 'Open Startup' },
{ href: 'https://shop.documenso.com', text: 'Shop', target: '_blank' },
{ href: 'https://status.documenso.com', text: 'Status', target: '_blank' },
{ href: 'mailto:support@documenso.com', text: 'Support', target: '_blank' },
{ href: '/oss-friends', text: 'OSS Friends' },
{ href: '/privacy', text: 'Privacy' },
];
export const Footer = ({ className, ...props }: FooterProps) => {
const { setTheme } = useTheme();
return (
<div className={cn('border-t py-12', className)} {...props}>
<div className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
<div>
<Link href="/">
<Image
src="/logo.png"
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
@@ -77,21 +76,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
))}
</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap justify-between gap-4 px-8 md:mt-12 lg:mt-24">
<div className="mx-auto mt-4 flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-8 md:mt-12 lg:mt-24">
<p className="text-muted-foreground text-sm">
© {new Date().getFullYear()} Documenso, Inc. All rights reserved.
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2.5">
<button type="button" className="text-muted-foreground" onClick={() => setTheme('light')}>
<Sun className="h-5 w-5" />
<span className="sr-only">Light</span>
</button>
<button type="button" className="text-muted-foreground" onClick={() => setTheme('dark')}>
<Moon className="h-5 w-5" />
<span className="sr-only">Dark</span>
</button>
<div className="flex flex-wrap">
<ThemeSwitcher />
</div>
</div>
</div>

View File

@@ -1,10 +1,12 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import LogoImage from '@documenso/assets/logo.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
@@ -25,7 +27,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
<div className="flex items-center space-x-4">
<Link href="/" className="z-10" onClick={() => setIsHamburgerMenuOpen(false)}>
<Image
src="/logo.png"
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}
@@ -62,7 +64,7 @@ export const Header = ({ className, ...props }: HeaderProps) => {
href="/open"
className="text-muted-foreground hover:text-muted-foreground/80 text-sm font-semibold"
>
Open
Open Startup
</Link>
<Link

View File

@@ -8,12 +8,11 @@ import { usePlausible } from 'next-plausible';
import { LuGithub } from 'react-icons/lu';
import { match } from 'ts-pattern';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
import { Widget } from './widget';
export type HeroProps = {

View File

@@ -8,6 +8,7 @@ import { FaXTwitter } from 'react-icons/fa6';
import { LiaDiscord } from 'react-icons/lia';
import { LuGithub } from 'react-icons/lu';
import LogoImage from '@documenso/assets/logo.png';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
export type MobileNavigationProps = {
@@ -30,7 +31,7 @@ export const MENU_NAVIGATION_LINKS = [
},
{
href: '/open',
text: 'Open',
text: 'Open Startup',
},
{
href: 'https://status.documenso.com',
@@ -63,7 +64,7 @@ export const MobileNavigation = ({ isMenuOpen, onMenuOpenChange }: MobileNavigat
<SheetContent className="w-full max-w-[400px]">
<Link href="/" className="z-10" onClick={handleMenuItemClick}>
<Image
src="/logo.png"
src={LogoImage}
alt="Documenso Logo"
className="dark:invert"
width={170}

View File

@@ -2,14 +2,13 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardBuildFigure from '@documenso/assets/images/card-build-figure.png';
import cardOpenFigure from '@documenso/assets/images/card-open-figure.png';
import cardTemplateFigure from '@documenso/assets/images/card-template-figure.png';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardBuildFigure from '~/assets/card-build-figure.png';
import cardOpenFigure from '~/assets/card-open-figure.png';
import cardTemplateFigure from '~/assets/card-template-figure.png';
export type OpenBuildTemplateBentoProps = HTMLAttributes<HTMLDivElement>;
export const OpenBuildTemplateBento = ({ className, ...props }: OpenBuildTemplateBentoProps) => {

View File

@@ -2,15 +2,14 @@ import { HTMLAttributes } from 'react';
import Image from 'next/image';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import cardConnectionsFigure from '@documenso/assets/images/card-connections-figure.png';
import cardPaidFigure from '@documenso/assets/images/card-paid-figure.png';
import cardSharingFigure from '@documenso/assets/images/card-sharing-figure.png';
import cardWidgetFigure from '@documenso/assets/images/card-widget-figure.png';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import backgroundPattern from '~/assets/background-pattern.png';
import cardConnectionsFigure from '~/assets/card-connections-figure.png';
import cardPaidFigure from '~/assets/card-paid-figure.png';
import cardSharingFigure from '~/assets/card-sharing-figure.png';
import cardWidgetFigure from '~/assets/card-widget-figure.png';
export type ShareConnectPaidWidgetBentoProps = HTMLAttributes<HTMLDivElement>;
export const ShareConnectPaidWidgetBento = ({

View File

@@ -1,233 +0,0 @@
'use server';
import { createElement } from 'react';
import { DateTime } from 'luxon';
import { PDFDocument } from 'pdf-lib';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { mailer } from '@documenso/email/mailer';
import { render } from '@documenso/email/render';
import { DocumentSelfSignedEmailTemplate } from '@documenso/email/templates/document-self-signed';
import { FROM_ADDRESS, FROM_NAME, SERVICE_USER_EMAIL } from '@documenso/lib/constants/email';
import { insertFieldInPDF } from '@documenso/lib/server-only/pdf/insert-field-in-pdf';
import { alphaid } from '@documenso/lib/universal/id';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { prisma } from '@documenso/prisma';
import {
DocumentDataType,
DocumentStatus,
FieldType,
Prisma,
ReadStatus,
SendStatus,
SigningStatus,
} from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing';
const ZCreateSinglePlayerDocumentSchema = z.object({
documentData: z.object({
data: z.string(),
type: z.nativeEnum(DocumentDataType),
}),
documentName: z.string(),
signer: z.object({
email: z.string().email().min(1),
name: z.string(),
signature: z.string(),
}),
fields: z.array(
z.object({
page: z.number(),
type: z.nativeEnum(FieldType),
positionX: z.number(),
positionY: z.number(),
width: z.number(),
height: z.number(),
}),
),
});
export type TCreateSinglePlayerDocumentSchema = z.infer<typeof ZCreateSinglePlayerDocumentSchema>;
/**
* Create and self signs a document.
*
* Returns the document token.
*/
export const createSinglePlayerDocument = async (
value: TCreateSinglePlayerDocumentSchema,
): Promise<string> => {
const { signer, fields, documentData, documentName } =
ZCreateSinglePlayerDocumentSchema.parse(value);
const document = await getFile({
data: documentData.data,
type: documentData.type,
});
const doc = await PDFDocument.load(document);
const createdAt = new Date();
const isBase64 = signer.signature.startsWith('data:image/png;base64,');
const signatureImageAsBase64 = isBase64 ? signer.signature : null;
const typedSignature = !isBase64 ? signer.signature : null;
// Update the document with the fields inserted.
for (const field of fields) {
const isSignatureField = field.type === FieldType.SIGNATURE;
await insertFieldInPDF(doc, {
...mapField(field, signer),
Signature: isSignatureField
? {
created: createdAt,
signatureImageAsBase64,
typedSignature,
// Dummy data.
id: -1,
recipientId: -1,
fieldId: -1,
}
: null,
// Dummy data.
id: -1,
documentId: -1,
recipientId: -1,
});
}
const unsignedPdfBytes = await doc.save();
const signedPdfBuffer = await signPdf({ pdf: Buffer.from(unsignedPdfBytes) });
const { token } = await prisma.$transaction(
async (tx) => {
const token = alphaid();
// Fetch service user who will be the owner of the document.
const serviceUser = await tx.user.findFirstOrThrow({
where: {
email: SERVICE_USER_EMAIL,
},
});
const { id: documentDataId } = await putFile({
name: `${documentName}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(signedPdfBuffer),
});
// Create document.
const document = await tx.document.create({
data: {
title: documentName,
status: DocumentStatus.COMPLETED,
documentDataId,
userId: serviceUser.id,
createdAt,
},
});
// Create recipient.
const recipient = await tx.recipient.create({
data: {
documentId: document.id,
name: signer.name,
email: signer.email,
token,
signedAt: createdAt,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
},
});
// Create fields and signatures.
await Promise.all(
fields.map(async (field) => {
const insertedField = await tx.field.create({
data: {
documentId: document.id,
recipientId: recipient.id,
...mapField(field, signer),
},
});
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
await tx.signature.create({
data: {
fieldId: insertedField.id,
signatureImageAsBase64,
typedSignature,
recipientId: recipient.id,
},
});
}
}),
);
return { document, token };
},
{
maxWait: 5000,
timeout: 30000,
},
);
const template = createElement(DocumentSelfSignedEmailTemplate, {
documentName: documentName,
assetBaseUrl: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
});
// Send email to signer.
await mailer.sendMail({
to: {
address: signer.email,
name: signer.name,
},
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: 'Document signed',
html: render(template),
text: render(template, { plainText: true }),
attachments: [{ content: signedPdfBuffer, filename: documentName }],
});
return token;
};
/**
* Map the fields provided by the user to fields compatible with Prisma.
*
* Signature fields are handled separately.
*
* @param field The field passed in by the user.
* @param signer The details of the person who is signing this document.
* @returns A field compatible with Prisma.
*/
const mapField = (
field: TCreateSinglePlayerDocumentSchema['fields'][number],
signer: TCreateSinglePlayerDocumentSchema['signer'],
) => {
const customText = match(field.type)
.with(FieldType.DATE, () => DateTime.now().toFormat('yyyy-MM-dd hh:mm a'))
.with(FieldType.EMAIL, () => signer.email)
.with(FieldType.NAME, () => signer.name)
.otherwise(() => '');
return {
type: field.type,
page: field.page,
positionX: new Prisma.Decimal(field.positionX),
positionY: new Prisma.Decimal(field.positionY),
width: new Prisma.Decimal(field.width),
height: new Prisma.Decimal(field.height),
customText,
inserted: true,
};
};

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { DocumentStatus, Signature } from '@documenso/prisma/client';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
@@ -14,7 +15,6 @@ import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import signingCelebration from '~/assets/signing-celebration.png';
import { ConfettiScreen } from '~/components/(marketing)/confetti-screen';
interface SinglePlayerModeSuccessProps {

View File

@@ -1,7 +1,6 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto';
import { readFileSync } from 'fs';
import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
@@ -88,7 +87,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const now = new Date();
const bytes64 = readFileSync('./public/documenso-supporter-pledge.pdf').toString('base64');
const bytes64 = await fetch(
new URL('@documenso/assets/documenso-supporter-pledge.pdf', import.meta.url),
)
.then(async (res) => res.arrayBuffer())
.then((buffer) => Buffer.from(buffer).toString('base64'));
const { id: documentDataId } = await prisma.documentData.create({
data: {

View File

@@ -2,6 +2,10 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
};
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }),

View File

@@ -18,6 +18,7 @@
},
"include": [
"next-env.d.ts",
"**/*.mjs",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",

View File

@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const fs = require('fs');
const path = require('path');
const { version } = require('./package.json');
@@ -10,22 +11,33 @@ ENV_FILES.forEach((file) => {
});
});
// !: This is a temp hack to get caveat working without placing it back in the public directory.
// !: By inlining this at build time we should be able to sign faster.
const FONT_CAVEAT_BYTES = fs.readFileSync(
path.join(__dirname, '../../packages/assets/fonts/caveat.ttf'),
);
/** @type {import('next').NextConfig} */
const config = {
output: process.env.DOCKER_OUTPUT ? 'standalone' : undefined,
experimental: {
outputFileTracingRoot: path.join(__dirname, '../../'),
serverActionsBodySizeLimit: '50mb',
},
reactStrictMode: true,
transpilePackages: [
'@documenso/assets',
'@documenso/ee',
'@documenso/lib',
'@documenso/prisma',
'@documenso/tailwind-config',
'@documenso/trpc',
'@documenso/ui',
'@documenso/email',
],
env: {
APP_VERSION: version,
NEXT_PUBLIC_PROJECT: 'web',
FONT_CAVEAT_URI: `data:font/ttf;base64,${FONT_CAVEAT_BYTES.toString('base64')}`,
},
modularizeImports: {
'lucide-react': {

View File

@@ -8,10 +8,12 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint:fix": "next lint --fix",
"clean": "rimraf .next && rimraf node_modules",
"copy:pdfjs": "node ../../scripts/copy-pdfjs.cjs"
},
"dependencies": {
"@documenso/assets": "*",
"@documenso/ee": "*",
"@documenso/lib": "*",
"@documenso/prisma": "*",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { redirect } from 'next/navigation';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
import { AdminNav } from './nav';

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';

View File

@@ -3,7 +3,7 @@ import { redirect } from 'next/navigation';
import { ChevronLeft, Users2 } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
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 { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';

View File

@@ -0,0 +1,189 @@
'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { History } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { type Document, type Recipient, SigningStatus } from '@documenso/prisma/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from '~/components/(dashboard)/avatar/stack-avatar';
const FORM_ID = 'resend-email';
export type ResendDocumentActionItemProps = {
document: Document;
recipients: Recipient[];
};
export const ZResendDocumentFormSchema = z.object({
recipients: z.array(z.number()).min(1, {
message: 'You must select at least one item.',
}),
});
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
export const ResendDocumentActionItem = ({
document,
recipients,
}: ResendDocumentActionItemProps) => {
const { data: session } = useSession();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const isOwner = document.userId === session?.user?.id;
const isDisabled =
!isOwner ||
document.status !== 'PENDING' ||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
const { mutateAsync: resendDocument } = trpcReact.document.resendDocument.useMutation();
const form = useForm<TResendDocumentFormSchema>({
resolver: zodResolver(ZResendDocumentFormSchema),
defaultValues: {
recipients: [],
},
});
const {
handleSubmit,
formState: { isSubmitting },
} = form;
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
try {
await resendDocument({ documentId: document.id, recipients });
toast({
title: 'Document re-sent',
description: 'Your document has been re-sent successfully.',
duration: 5000,
});
setIsOpen(false);
} catch (err) {
toast({
title: 'Something went wrong',
description: 'This document could not be re-sent at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-sm" hideClose>
<DialogHeader>
<DialogTitle>
<h1 className="text-center text-xl">Who do you want to remind?</h1>
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
<FormField
control={form.control}
name="recipients"
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
>
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
})}
>
<StackAvatar
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
{recipient.email}
</FormLabel>
<FormControl>
<Checkbox
className="h-5 w-5 rounded-full data-[state=checked]:border-black data-[state=checked]:bg-black "
checkClassName="text-white"
value={recipient.id}
checked={value.includes(recipient.id)}
onCheckedChange={(checked: boolean) =>
checked
? onChange([...value, recipient.id])
: onChange(value.filter((v) => v !== recipient.id))
}
/>
</FormControl>
</FormItem>
))}
</>
)}
/>
</form>
</Form>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogClose>
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
Send reminder
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -2,12 +2,15 @@
import Link from 'next/link';
import { Edit, Pencil, Share } from 'lucide-react';
import { Download, Edit, Pencil } 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 { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
export type DataTableActionButtonProps = {
@@ -33,6 +36,42 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const onDownloadClick = async () => {
let document: DocumentWithData | null = null;
if (!recipient) {
document = await trpcClient.document.getDocumentById.query({
id: row.id,
});
} else {
document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
}
const documentData = document?.documentData;
if (!documentData) {
return;
}
const documentBytes = await getFile(documentData);
const blob = new Blob([documentBytes], {
type: 'application/pdf',
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
return match({
isOwner,
isRecipient,
@@ -42,7 +81,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
isSigned,
})
.with({ isOwner: true, isDraft: true }, () => (
<Button className="w-24" asChild>
<Button className="w-32" asChild>
<Link href={`/documents/${row.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
@@ -50,23 +89,24 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
</Button>
))
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-24" asChild>
<Button className="w-32" asChild>
<Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</Link>
</Button>
))
.otherwise(() => (
<DocumentShareButton
documentId={row.id}
token={recipient?.token}
trigger={({ loading }) => (
<Button className="w-24" loading={loading}>
{!loading && <Share className="-ml-1 mr-2 h-4 w-4" />}
Share
.with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}>
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
Sign
</Button>
)}
/>
));
))
.with({ isComplete: true }, () => (
<Button className="w-32" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
Download
</Button>
))
.otherwise(() => <div></div>);
};

View File

@@ -8,7 +8,6 @@ import {
Copy,
Download,
Edit,
History,
Loader,
MoreHorizontal,
Pencil,
@@ -19,8 +18,9 @@ import {
import { useSession } from 'next-auth/react';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { Document, DocumentStatus, Recipient, User } from '@documenso/prisma/client';
import { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
@@ -31,6 +31,7 @@ import {
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { ResendDocumentActionItem } from './_action-items/resend-document';
import { DeleteDraftDocumentDialog } from './delete-draft-document-dialog';
import { DuplicateDocumentDialog } from './duplicate-document-dialog';
@@ -87,15 +88,17 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
});
const link = window.document.createElement('a');
const baseTitle = row.title.includes('.pdf') ? row.title.split('.pdf')[0] : row.title;
link.href = window.URL.createObjectURL(blob);
link.download = row.title || 'document.pdf';
link.download = baseTitle ? `${baseTitle}_signed.pdf` : 'document.pdf';
link.click();
window.URL.revokeObjectURL(link.href);
};
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return (
<DropdownMenu>
<DropdownMenuTrigger>
@@ -141,19 +144,16 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuItem disabled>
<History className="mr-2 h-4 w-4" />
Resend
</DropdownMenuItem>
<ResendDocumentActionItem document={row} recipients={nonSignedRecipients} />
<DocumentShareButton
documentId={row.id}
token={recipient?.token}
token={isOwner ? undefined : recipient?.token}
trigger={({ loading, disabled }) => (
<DropdownMenuItem disabled={disabled || isDraft} onSelect={(e) => e.preventDefault()}>
<div className="flex items-center">
{loading ? <Loader className="mr-2 h-4 w-4" /> : <Share className="mr-2 h-4 w-4" />}
Share
Share Signing Card
</div>
</DropdownMenuItem>
)}

View File

@@ -6,8 +6,8 @@ import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { FindResultSet } from '@documenso/lib/types/find-result-set';
import { Document, Recipient, User } from '@documenso/prisma/client';
import type { FindResultSet } from '@documenso/lib/types/find-result-set';
import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';

View File

@@ -26,14 +26,14 @@ export const DuplicateDocumentDialog = ({
const router = useRouter();
const { toast } = useToast();
const { data, isLoading } = trpcReact.document.getDocumentById.useQuery({
const { data: document, isLoading } = trpcReact.document.getDocumentById.useQuery({
id,
});
const documentData = data?.documentData
const documentData = document?.documentData
? {
...data.documentData,
data: data.documentData.initialData,
...document.documentData,
data: document.documentData.initialData,
}
: undefined;
@@ -78,7 +78,7 @@ export const DuplicateDocumentDialog = ({
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll ">
<LazyPDFViewer key={data?.id} documentData={documentData} />
<LazyPDFViewer key={document?.id} documentData={documentData} />
</div>
)}

View File

@@ -1,6 +1,6 @@
import Link from 'next/link';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { findDocuments } from '@documenso/lib/server-only/document/find-documents';
import { getStats } from '@documenso/lib/server-only/document/get-stats';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
@@ -8,10 +8,8 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import {
PeriodSelectorValue,
isPeriodSelectorValue,
} from '~/components/(dashboard)/period-selector/types';
import type { PeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
import { DocumentsDataTable } from './data-table';
@@ -80,7 +78,12 @@ export default async function DocumentsPage({ searchParams = {} }: DocumentsPage
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
].map((value) => (
<TabsTrigger key={value} className="min-w-[60px]" value={value} asChild>
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link href={getTabHref(value)} scroll={false}>
<DocumentStatus status={value} />

View File

@@ -6,6 +6,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
@@ -22,6 +23,7 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
@@ -79,7 +81,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<div className={cn('relative', className)}>
<DocumentDropzone
className="min-h-[40vh]"
disabled={remaining.documents === 0}
disabled={remaining.documents === 0 || !session?.user.emailVerified}
onDrop={onFileDrop}
/>

View File

@@ -6,9 +6,10 @@ import { getServerSession } from 'next-auth';
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/server';
import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Header } from '~/components/(dashboard)/layout/header';
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
import { NextAuthProvider } from '~/providers/next-auth';
@@ -30,6 +31,7 @@ export default async function AuthenticatedDashboardLayout({
return (
<NextAuthProvider session={session}>
<LimitsProvider>
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
<Header user={user} />
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>

View File

@@ -5,8 +5,9 @@ import {
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export const createBillingPortal = async () => {

View File

@@ -7,8 +7,8 @@ import {
getStripeCustomerById,
} from '@documenso/ee/server-only/stripe/get-customer';
import { getPortalSession } from '@documenso/ee/server-only/stripe/get-portal-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { Stripe } from '@documenso/lib/server-only/stripe';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
export type CreateCheckoutOptions = {

View File

@@ -4,9 +4,9 @@ import { match } from 'ts-pattern';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import { Stripe } from '@documenso/lib/server-only/stripe';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
import { LocaleDate } from '~/components/formatter/locale-date';

View File

@@ -1,4 +1,4 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { PasswordForm } from '~/components/forms/password';

View File

@@ -1,4 +1,4 @@
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { ProfileForm } from '~/components/forms/profile';

View File

@@ -3,17 +3,16 @@ import { NextResponse } from 'next/server';
import { P, match } from 'ts-pattern';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug';
import type { ShareHandlerAPIResponse } from '~/pages/api/share';
import { Logo } from '~/components/branding/logo';
import { getAssetBuffer } from '~/helpers/get-asset-buffer';
export const runtime = 'edge';
const CARD_OFFSET_TOP = 152;
const CARD_OFFSET_LEFT = 350;
const CARD_WIDTH = 500;
const CARD_HEIGHT = 250;
const CARD_OFFSET_TOP = 173;
const CARD_OFFSET_LEFT = 307;
const CARD_WIDTH = 590;
const CARD_HEIGHT = 337;
const size = {
const IMAGE_SIZE = {
width: 1200,
height: 630,
};
@@ -24,15 +23,27 @@ type SharePageOpenGraphImageProps = {
export async function GET(_request: Request, { params: { slug } }: SharePageOpenGraphImageProps) {
const [interSemiBold, interRegular, caveatRegular, shareFrameImage] = await Promise.all([
getAssetBuffer('/fonts/inter-semibold.ttf'),
getAssetBuffer('/fonts/inter-regular.ttf'),
getAssetBuffer('/fonts/caveat-regular.ttf'),
getAssetBuffer('/static/og-share-frame.png'),
fetch(new URL('@documenso/assets/fonts/inter-semibold.ttf', import.meta.url)).then(
async (res) => res.arrayBuffer(),
),
fetch(new URL('@documenso/assets/fonts/inter-regular.ttf', import.meta.url)).then(async (res) =>
res.arrayBuffer(),
),
fetch(new URL('@documenso/assets/fonts/caveat-regular.ttf', import.meta.url)).then(
async (res) => res.arrayBuffer(),
),
fetch(new URL('@documenso/assets/static/og-share-frame2.png', import.meta.url)).then(
async (res) => res.arrayBuffer(),
),
]);
const recipientOrSender = await getRecipientOrSenderByShareLinkSlug({ slug }).catch(() => null);
const baseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
if (!recipientOrSender) {
const recipientOrSender: ShareHandlerAPIResponse = await fetch(
new URL(`/api/share?slug=${slug}`, baseUrl),
).then(async (res) => res.json());
if ('error' in recipientOrSender) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
@@ -60,11 +71,6 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
{/* @ts-expect-error Lack of typing from ImageResponse */}
<img src={shareFrameImage} alt="og-share-frame" tw="absolute inset-0 w-full h-full" />
<div tw="absolute top-20 flex w-full items-center justify-center">
{/* @ts-expect-error Lack of typing from ImageResponse */}
<Logo tw="h-8 w-60" />
</div>
{signatureImage ? (
<div
tw="absolute py-6 px-12 flex items-center justify-center text-center"
@@ -96,40 +102,28 @@ export async function GET(_request: Request, { params: { slug } }: SharePageOpen
</p>
)}
{/* <div
tw="absolute flex items-center justify-center text-slate-500"
style={{
top: `${CARD_OFFSET_TOP + CARD_HEIGHT - 45}px`,
left: `${CARD_OFFSET_LEFT}`,
width: `${CARD_WIDTH}px`,
fontSize: '30px',
}}
>
{signatureName}
</div> */}
<div
tw="absolute flex flex-col items-center justify-center pt-12 w-full"
tw="absolute flex w-full"
style={{
top: `${CARD_OFFSET_TOP + CARD_HEIGHT}px`,
top: `${CARD_OFFSET_TOP - 78}px`,
left: `${CARD_OFFSET_LEFT}px`,
}}
>
<h2
tw="text-3xl text-slate-500"
tw="text-xl"
style={{
color: '#828282',
fontFamily: 'Inter',
fontWeight: 600,
fontWeight: 700,
}}
>
{isRecipient
? 'I just signed with Documenso and you can too!'
: 'I just sent a document with Documenso and you can too!'}
{isRecipient ? 'Document Signed!' : 'Document Sent!'}
</h2>
</div>
</div>
),
{
...size,
...IMAGE_SIZE,
fonts: [
{
name: 'Caveat',

View File

@@ -11,7 +11,7 @@ type SharePageProps = {
export function generateMetadata({ params: { slug } }: SharePageProps) {
return {
title: 'Documenso - Share',
description: 'I just signed a document with Documenso!',
description: 'I just signed a document in style with Documenso!',
openGraph: {
title: 'Documenso - Join the open source signing revolution',
description: 'I just signed with Documenso!',

View File

@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import { CheckCircle2, Clock8 } from 'lucide-react';
import { match } from 'ts-pattern';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -13,8 +14,6 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import signingCelebration from '~/assets/signing-celebration.png';
export type CompletedSigningPageProps = {
params: {
token?: string;

View File

@@ -19,6 +19,7 @@ import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useRequiredSigningContext } from './provider';
import { SignDialog } from './sign-dialog';
export type SigningFormProps = {
document: Document;
@@ -31,7 +32,6 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const {
@@ -45,6 +45,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const onFormSubmit = async () => {
setValidateUninsertedFields(true);
const isFieldsValid = validateFieldsInserted(fields);
if (!isFieldsValid) {
@@ -132,9 +133,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
Cancel
</Button>
<Button className="w-full" type="submit" size="lg" loading={isSubmitting}>
Complete
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
/>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
import { NextAuthProvider } from '~/providers/next-auth';

View File

@@ -3,7 +3,7 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';

View File

@@ -0,0 +1,77 @@
import { useState } from 'react';
import { Document, Field } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
export type SignDialogProps = {
isSubmitting: boolean;
document: Document;
fields: Field[];
onSignatureComplete: () => void | Promise<void>;
};
export const SignDialog = ({
isSubmitting,
document,
fields,
onSignatureComplete,
}: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false);
const isComplete = fields.every((field) => field.inserted);
return (
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogTrigger asChild>
<Button
className="w-full"
type="button"
size="lg"
disabled={!isComplete}
loading={isSubmitting}
>
Complete
</Button>
</DialogTrigger>
<DialogContent>
<div className="text-center">
<div className="text-xl font-semibold text-neutral-800">Sign Document</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
You are about to finish signing "{document.title}". Are you sure?
</div>
</div>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
onClick={() => {
setShowDialog(false);
}}
>
Cancel
</Button>
<Button
type="button"
className="flex-1"
disabled={!isComplete}
loading={isSubmitting}
onClick={onSignatureComplete}
>
Sign
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -2,7 +2,7 @@ import React from 'react';
import Image from 'next/image';
import backgroundPattern from '~/assets/background-pattern.png';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
type UnauthenticatedLayoutProps = {
children: React.ReactNode;

View File

@@ -0,0 +1,97 @@
import Link from 'next/link';
import { AlertTriangle, CheckCircle2, XCircle, XOctagon } from 'lucide-react';
import { verifyEmail } from '@documenso/lib/server-only/user/verify-email';
import { Button } from '@documenso/ui/primitives/button';
export type PageProps = {
params: {
token: string;
};
};
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
if (!token) {
return (
<div className="w-full">
<div className="mb-4 text-red-300">
<XOctagon />
</div>
<h2 className="text-4xl font-semibold">No token provided</h2>
<p className="text-muted-foreground mt-2 text-base">
It seems that there is no token provided. Please check your email and try again.
</p>
</div>
);
}
const verified = await verifyEmail({ token });
if (verified === null) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<AlertTriangle className="h-10 w-10 text-yellow-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Something went wrong</h2>
<p className="text-muted-foreground mt-4">
We were unable to verify your email. If your email is not verified already, please try
again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
if (!verified) {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Your token has expired!</h2>
<p className="text-muted-foreground mt-4">
It seems that the provided token has expired. We've just sent you another token, please
check your email and try again.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<CheckCircle2 className="h-10 w-10 text-green-500" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Email Confirmed!</h2>
<p className="text-muted-foreground mt-4">
Your email has been successfully confirmed! You can now use all features of Documenso.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import Link from 'next/link';
import { XCircle } from 'lucide-react';
import { Button } from '@documenso/ui/primitives/button';
export default function EmailVerificationWithoutTokenPage() {
return (
<div className="flex w-full items-start">
<div className="mr-4 mt-1 hidden md:block">
<XCircle className="text-destructive h-10 w-10" strokeWidth={2} />
</div>
<div>
<h2 className="text-2xl font-bold md:text-4xl">Uh oh! Looks like you're missing a token</h2>
<p className="text-muted-foreground mt-4">
It seems that there is no token provided, if you are trying to verify your email please
follow the link in your email.
</p>
<Button className="mt-4" asChild>
<Link href="/">Go back home</Link>
</Button>
</div>
</div>
);
}

View File

@@ -12,7 +12,6 @@ import { Toaster } from '@documenso/ui/primitives/toaster';
import { TooltipProvider } from '@documenso/ui/primitives/tooltip';
import { ThemeProvider } from '~/providers/next-theme';
import { PlausibleProvider } from '~/providers/plausible';
import { PostHogPageview } from '~/providers/posthog';
import './globals.css';
@@ -69,13 +68,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body>
<LocaleProvider locale={locale}>
<FeatureFlagProvider initialFlags={flags}>
<PlausibleProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<TrpcProvider>{children}</TrpcProvider>
</TooltipProvider>
</ThemeProvider>
</PlausibleProvider>
<Toaster />
</FeatureFlagProvider>

View File

@@ -1,6 +1,6 @@
import Link from 'next/link';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { Button } from '@documenso/ui/primitives/button';
import NotFoundPartial from '~/components/partials/not-found';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 MiB

View File

@@ -0,0 +1,46 @@
'use client';
import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { StackAvatar } from './stack-avatar';
export type AvatarWithRecipientProps = {
recipient: Recipient;
};
export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
const [, copy] = useCopyToClipboard();
const { toast } = useToast();
const onRecipientClick = () => {
void copy(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`).then(() => {
toast({
title: 'Copied to clipboard',
description: 'The signing link has been copied to your clipboard.',
});
});
};
return (
<div className="my-1 flex cursor-pointer items-center gap-2" onClick={onRecipientClick}>
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span
className="text-muted-foreground text-sm hover:underline"
title="Click to copy signing link for sending to recipient"
>
{recipient.email}
</span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { Recipient } from '@documenso/prisma/client';
import type { Recipient } from '@documenso/prisma/client';
import {
Tooltip,
TooltipContent,
@@ -8,6 +8,7 @@ import {
TooltipTrigger,
} from '@documenso/ui/primitives/tooltip';
import { AvatarWithRecipient } from './avatar-with-recipient';
import { StackAvatar } from './stack-avatar';
import { StackAvatars } from './stack-avatars';
@@ -85,15 +86,7 @@ export const StackAvatarsWithTooltip = ({
<div>
<h1 className="text-base font-medium">Opened</h1>
{openedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
))}
</div>
)}
@@ -102,15 +95,7 @@ export const StackAvatarsWithTooltip = ({
<div>
<h1 className="text-base font-medium">Uncompleted</h1>
{uncompletedRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<span className="text-muted-foreground text-sm">{recipient.email}</span>
</div>
<AvatarWithRecipient key={recipient.id} recipient={recipient} />
))}
</div>
)}

View File

@@ -1,6 +1,7 @@
'use client';
import { HTMLAttributes, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { Search } from 'lucide-react';
@@ -14,6 +15,14 @@ export type DesktopNavProps = HTMLAttributes<HTMLDivElement>;
export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
// const pathname = usePathname();
const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
useEffect(() => {
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown';
const isMacOS = /Macintosh|Mac\s+OS\s+X/i.test(userAgent);
setModifierKey(isMacOS ? '⌘' : 'Ctrl');
}, []);
return (
<div
@@ -33,8 +42,8 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</div>
<div>
<div className="text-muted-foreground bg-muted rounded-md px-1.5 py-0.5 font-mono text-xs">
Ctrl+K
<div className="text-muted-foreground bg-muted flex items-center rounded-md px-1.5 py-0.5 text-xs tracking-wider">
{modifierKey}+K
</div>
</div>
</Button>

View File

@@ -0,0 +1,123 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { ONE_SECOND } from '@documenso/lib/constants/time';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type VerifyEmailBannerProps = {
email: string;
};
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
const { mutateAsync: sendConfirmationEmail, isLoading } =
trpc.profile.sendConfirmationEmail.useMutation();
const onResendConfirmationEmail = async () => {
try {
setIsButtonDisabled(true);
await sendConfirmationEmail({ email: email });
toast({
title: 'Success',
description: 'Verification email sent successfully.',
});
setIsOpen(false);
setTimeout(() => setIsButtonDisabled(false), RESEND_CONFIRMATION_EMAIL_TIMEOUT);
} catch (err) {
setIsButtonDisabled(false);
toast({
title: 'Error',
description: 'Something went wrong while sending the confirmation email.',
variant: 'destructive',
});
}
};
useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < 24 * 60 * 60 * 1000) {
return;
}
}
setIsOpen(true);
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);
return (
<>
<div className="bg-yellow-200 dark:bg-yellow-400">
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 text-sm font-medium text-yellow-900">
<div className="flex items-center">
<AlertTriangle className="mr-2.5 h-5 w-5" />
Verify your email address to unlock all features.
</div>
<div>
<Button
variant="ghost"
className="h-auto px-2.5 py-1.5 text-yellow-900 hover:bg-yellow-100 hover:text-yellow-900 dark:hover:bg-yellow-500"
disabled={isButtonDisabled}
onClick={() => setIsOpen(true)}
size="sm"
>
{isButtonDisabled ? 'Verification Email Sent' : 'Verify Now'}
</Button>
</div>
</div>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogTitle>Verify your email address</DialogTitle>
<DialogDescription>
We've sent a confirmation email to <strong>{email}</strong>. Please check your inbox and
click the link in the email to verify your account.
</DialogDescription>
<div>
<Button
disabled={isButtonDisabled}
loading={isLoading}
onClick={onResendConfirmationEmail}
>
{isLoading ? 'Sending...' : 'Resend Confirmation Email'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -1,8 +1,8 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
import { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
import type { TAddFieldsFormSchema } from '@documenso/ui/primitives/document-flow/add-fields.types';
export type AddFieldsActionInput = TAddFieldsFormSchema & {
documentId: number;

View File

@@ -1,8 +1,8 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
import type { TAddSignersFormSchema } from '@documenso/ui/primitives/document-flow/add-signers.types';
export type AddSignersActionInput = TAddSignersFormSchema & {
documentId: number;

View File

@@ -1,9 +1,9 @@
'use server';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-session';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types';
export type CompleteDocumentActionInput = TAddSubjectFormSchema & {
documentId: number;

View File

@@ -20,9 +20,18 @@ import { FormErrorMessage } from '../form/form-error-message';
export const ZPasswordFormSchema = z
.object({
currentPassword: z.string().min(6).max(72),
password: z.string().min(6).max(72),
repeatedPassword: z.string().min(6).max(72),
currentPassword: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
password: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
repeatedPassword: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',

View File

@@ -28,7 +28,7 @@ const LOGIN_REDIRECT_PATH = '/documents';
export const ZSignInFormSchema = z.object({
email: z.string().email().min(1),
password: z.string().min(6).max(72),
password: z.string().min(6, { message: 'Invalid password' }).max(72),
});
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;

View File

@@ -21,7 +21,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZSignUpFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
email: z.string().email().min(1),
password: z.string().min(6).max(72),
password: z
.string()
.min(6, { message: 'Password should contain at least 6 characters' })
.max(72, { message: 'Password should not contain more than 72 characters' }),
signature: z.string().min(1, { message: 'We need your signature to sign documents' }),
});
@@ -134,6 +137,7 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<div>

View File

@@ -6,11 +6,10 @@ import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import backgroundPattern from '~/assets/background-pattern.png';
export type NotFoundPartialProps = {
children?: React.ReactNode;
};

View File

@@ -0,0 +1,21 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { prisma } from '@documenso/prisma';
export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
try {
await prisma.$queryRaw`SELECT 1`;
return res.json({
status: 'ok',
message: 'All systems operational',
});
} catch (err) {
console.error(err);
return res.status(500).json({
status: 'error',
message: err.message,
});
}
}

View File

@@ -0,0 +1,23 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/share/get-recipient-or-sender-by-share-link-slug';
export type ShareHandlerAPIResponse =
| Awaited<ReturnType<typeof getRecipientOrSenderByShareLinkSlug>>
| { error: string };
export default async function shareHandler(req: NextApiRequest, res: NextApiResponse) {
try {
if (typeof req.query.slug !== 'string') {
throw new Error('Invalid slug');
}
const data = await getRecipientOrSenderByShareLinkSlug({
slug: req.query.slug,
});
return res.json(data);
} catch (error) {
return res.status(404).json({ error: 'Not found' });
}
}

View File

@@ -2,6 +2,10 @@ import * as trpcNext from '@documenso/trpc/server/adapters/next';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
export const config = {
maxDuration: 60,
};
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: async ({ req, res }) => createTrpcContext({ req, res }),

View File

@@ -1,53 +1,86 @@
###########################
# BASE CONTAINER #
###########################
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS production_deps
WORKDIR /app
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
# Copy our current monorepo
COPY . .
RUN npm ci --production
# Install dependencies only when needed
###########################
# BUILDER CONTAINER #
###########################
FROM base AS builder
WORKDIR /app
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache jq
WORKDIR /app
# Copy our current monorepo
COPY . .
RUN TURBO_VERSION="$(npm list --package-lock-only --json turbo | jq -r '.dependencies.turbo.version')"
RUN npm install -g "turbo@$TURBO_VERSION"
# Outputs to the /out folder
# source: https://turbo.build/repo/docs/reference/command-line-reference/prune#--docker
RUN turbo prune --scope=@documenso/web --docker
###########################
# INSTALLER CONTAINER #
###########################
FROM base AS installer
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache jq
# Required for node_modules/aws-crt
RUN apk add --no-cache make cmake g++
WORKDIR /app
# Disable husky from installing hooks
ENV HUSKY 0
ENV DOCKER_OUTPUT 1
ENV NEXT_TELEMETRY_DISABLED 1
# Uncomment and use build args to enable remote caching
# ARG TURBO_TEAM
# ENV TURBO_TEAM=$TURBO_TEAM
# ARG TURBO_TOKEN
# ENV TURBO_TOKEN=$TURBO_TOKEN
# First install the dependencies (as they change less often)
COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/package-lock.json ./package-lock.json
RUN npm ci
RUN npm run build --workspaces
# Then copy all the source code (as it changes more often)
COPY --from=builder /app/out/full/ .
# Finally copy the turbo.json file so that we can run turbo commands
COPY turbo.json turbo.json
# Production image, copy all the files and run next
RUN TURBO_VERSION="$(npm list --package-lock-only --json turbo | jq -r '.dependencies.turbo.version')"
RUN npm install -g "turbo@$TURBO_VERSION"
RUN turbo run build --filter=@documenso/web...
###########################
# RUNNER CONTAINER #
###########################
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
COPY --from=production_deps --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=production_deps --chown=nextjs:nodejs /app/package-lock.json ./package-lock.json
COPY --from=installer /app/apps/web/next.config.js .
COPY --from=installer /app/apps/web/package.json .
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next ./.next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
EXPOSE 3000
ENV PORT 3000
CMD ["npm", "run", "start"]
CMD node apps/web/server.js

View File

@@ -7,6 +7,6 @@ cd "$MONOREPO_ROOT"
npm ci
npm run db-migrate:dev
npm run prisma:migrate-dev
npm run dev

32
docker/compose-test.yml Normal file
View File

@@ -0,0 +1,32 @@
name: documenso_test
services:
database:
image: postgres:15
environment:
- POSTGRES_USER=documenso
- POSTGRES_PASSWORD=password
- POSTGRES_DB=documenso
ports:
- 54322:5432
inbucket:
image: inbucket/inbucket
# ports:
# - 9000:9000
# - 2500:2500
# - 1100:1100
documenso:
build:
context: ../
dockerfile: docker/Dockerfile
depends_on:
- database
- inbucket
env_file:
- ../.env.example
environment:
- NEXT_PRIVATE_DATABASE_URL=postgres://documenso:password@database:5432/documenso
- NEXT_PRIVATE_DIRECT_DATABASE_URL=postgres://documenso:password@database:5432/documenso
ports:
- 3000:3000

1736
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,9 @@
"build": "turbo run build",
"build:web": "turbo run build --filter=@documenso/web",
"dev": "turbo run dev --filter=@documenso/web --filter=@documenso/marketing",
"start": "cd apps && cd web && next start",
"start": "turbo run start --filter=@documenso/web --filter=@documenso/marketing",
"lint": "turbo run lint",
"lint:fix": "turbo run lint:fix",
"format": "prettier --write \"**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}\"",
"prepare": "husky install",
"commitlint": "commitlint --edit",
@@ -46,7 +47,7 @@
"packages/*"
],
"dependencies": {
"react-hotkeys-hook": "^4.4.1",
"recharts": "^2.7.2"
"recharts": "^2.7.2",
"react-hotkeys-hook": "^4.4.1"
}
}

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