Merge branch 'main' into feat/accept-text-signature

This commit is contained in:
Ephraim Atta-Duncan
2024-01-16 15:26:39 +00:00
168 changed files with 4965 additions and 1533 deletions

View File

@ -77,16 +77,14 @@ NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY=
NEXT_PRIVATE_STRIPE_API_KEY= NEXT_PRIVATE_STRIPE_API_KEY=
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET= NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID= NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID=
# [[FEATURES]] # [[FEATURES]]
# OPTIONAL: Leave blank to disable PostHog and feature flags. # OPTIONAL: Leave blank to disable PostHog and feature flags.
NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_KEY=""
# OPTIONAL: Defines the host to use for PostHog.
NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
# OPTIONAL: Leave blank to disable billing. # OPTIONAL: Leave blank to disable billing.
NEXT_PUBLIC_FEATURE_BILLING_ENABLED= NEXT_PUBLIC_FEATURE_BILLING_ENABLED=
# OPTIONAL: Leave blank to allow users to signup through /signup page.
NEXT_PUBLIC_DISABLE_SIGNUP=
# This is only required for the marketing site # This is only required for the marketing site
# [[REDIS]] # [[REDIS]]

View File

@ -12,6 +12,10 @@ jobs:
if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User' if: ${{ github.event.issue.assignee }} && github.event.action == 'assigned' && github.event.sender.type == 'User'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:

View File

@ -12,6 +12,10 @@ jobs:
if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested') if: ${{ github.event.pull_request.user.login }} && github.event.action == ('opened' || 'reopened' || 'ready_for_review' || 'review_requested')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:

View File

@ -16,6 +16,24 @@ jobs:
name: Validate PR title name: Validate PR title
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check PR creator's previous activity
id: check_activity
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
ACTIVITY=$(curl -s "https://api.github.com/search/commits?q=author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
if [ "$ACTIVITY" -eq 0 ]; then
echo "::set-output name=is_new::true"
else
echo "::set-output name=is_new::false"
fi
- name: Count PRs created by user
id: count_prs
run: |
CREATOR=$(curl -s "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" | jq -r '.user.login')
PR_COUNT=$(curl -s "https://api.github.com/search/issues?q=type:pr+is:open+author:${CREATOR}+repo:${{ github.repository }}" | jq -r '.total_count')
echo "::set-output name=pr_count::$PR_COUNT"
- uses: amannn/action-semantic-pull-request@v5 - uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title id: lint_pr_title
env: env:
@ -36,7 +54,7 @@ jobs:
${{ steps.lint_pr_title.outputs.error_message }} ${{ steps.lint_pr_title.outputs.error_message }}
``` ```
- if: ${{ steps.lint_pr_title.outputs.error_message == null }} - if: ${{ steps.lint_pr_title.outputs.error_message == null && steps.check_activity.outputs.is_new == 'false' && steps.count_prs.outputs.pr_count < 2}}
uses: marocchino/sticky-pull-request-comment@v2 uses: marocchino/sticky-pull-request-comment@v2
with: with:
header: pr-title-lint-error header: pr-title-lint-error

View File

@ -15,11 +15,10 @@ jobs:
- uses: actions/stale@v4 - uses: actions/stale@v4
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-pr-stale: 30 days-before-pr-stale: 90
days-before-issue-stale: 30 days-before-issue-stale: 90
stale-issue-message: 'This issue has not seen activity for a while. It will be closed in 30 days unless further activity is detected' days-before-issue-close: 180
stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.' stale-pr-message: 'This PR has not seen activitiy for a while. It will be closed in 30 days unless further activity is detected.'
close-issue-message: 'This issue has been closed because of inactivity.'
close-pr-message: 'This PR has been closed because of inactivity.' close-pr-message: 'This PR has been closed because of inactivity.'
exempt-pr-labels: 'WIP,on-hold,needs review' exempt-pr-labels: 'WIP,on-hold,needs review'
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned' exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned,needs triage'

View File

@ -13,9 +13,9 @@
· ·
<a href="https://github.com/documenso/documenso/issues">Issues</a> <a href="https://github.com/documenso/documenso/issues">Issues</a>
· ·
<a href="https://github.com/documenso/documenso/milestones">Roadmap</a> <a href="https://documen.so/live">Upcoming Releases</a>
· ·
<a href="https://documen.so/launches">Upcoming Launches</a> <a href="https://documen.so/roadmap">Roadmap</a>
</p> </p>
</p> </p>
@ -115,10 +115,12 @@ To run Documenso locally, you will need
Want to get up and running quickly? Follow these steps: Want to get up and running quickly? Follow these steps:
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device. 1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
After forking the repository, clone it to your local device by using the following command:
```sh ```sh
git clone https://github.com/documenso/documenso git clone https://github.com/<your-username>/documenso
``` ```
2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults. 2. Set up your `.env` file using the recommendations in the `.env.example` file. Alternatively, just run `cp .env.example .env` to get started with our handpicked defaults.
@ -152,10 +154,12 @@ npm run d
Follow these steps to setup Documenso on your local machine: Follow these steps to setup Documenso on your local machine:
1. [Clone the repository](https://help.github.com/articles/cloning-a-repository/) it to your local device. 1. [Fork this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks) to your GitHub account.
After forking the repository, clone it to your local device by using the following command:
```sh ```sh
git clone https://github.com/documenso/documenso git clone https://github.com/<your-username>/documenso
``` ```
2. Run `npm i` in the root directory 2. Run `npm i` in the root directory
@ -280,12 +284,16 @@ WantedBy=multi-user.target
### Railway ### Railway
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/DjrRRX) [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/bG6D4p)
### Render ### Render
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/documenso/documenso)
### Koyeb
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?type=git&repository=github.com/documenso/documenso&branch=main&name=documenso-app&builder=dockerfile&dockerfile=/docker/Dockerfile)
## Troubleshooting ## Troubleshooting
### I'm not receiving any emails when using the developer quickstart. ### I'm not receiving any emails when using the developer quickstart.

View File

@ -1,6 +1,6 @@
--- ---
title: Announcing Pre-Seed and Open Metrics title: Announcing Pre-Seed and Open Metrics
description: We are exicited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso. description: We are excited to report the closing of our Pre-Seed round. You can find the juicy details on our new /open page. Yes, it was signed using Documenso.
authorName: 'Timur Ercan' authorName: 'Timur Ercan'
authorImage: '/blog/blog-author-timur.jpeg' authorImage: '/blog/blog-author-timur.jpeg'
authorRole: 'Co-Founder' authorRole: 'Co-Founder'

View File

@ -30,7 +30,7 @@ We kicked off [Malfunction Mania](https://documenso.com/blog/malfunction-mania)
## Documenso Merch Shop ## Documenso Merch Shop
The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contrinuting to Documenso. The shirt will be available in our [merch shop](https://documen.so/shop) via a unique discount code. While the shirt will be gone after Malfunction Mania, the shop is here to stay and provide a well-deserved reward for great community members and contributors. All items can be earned by contributing to Documenso.
<figure> <figure>
<MdxNextImage <MdxNextImage

View File

@ -36,7 +36,7 @@
"react-hook-form": "^7.43.9", "react-hook-form": "^7.43.9",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"recharts": "^2.7.2", "recharts": "^2.7.2",
"sharp": "0.32.5", "sharp": "0.33.1",
"typescript": "5.2.2", "typescript": "5.2.2",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },

View File

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

View File

@ -3,7 +3,7 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
export type MonthlyNewUsersChartProps = { export type MonthlyNewUsersChartProps = {
@ -22,7 +22,7 @@ export const MonthlyNewUsersChart = ({ className, data }: MonthlyNewUsersChartPr
return ( return (
<div className={cn('flex flex-col', className)}> <div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4"> <div className="flex items-center px-4">
<h3 className="text-lg font-semibold">Monthly New Users</h3> <h3 className="text-lg font-semibold">New Users</h3>
</div> </div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow"> <div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">

View File

@ -3,7 +3,7 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth'; import type { GetUserMonthlyGrowthResult } from '@documenso/lib/server-only/user/get-user-monthly-growth';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
export type MonthlyTotalUsersChartProps = { export type MonthlyTotalUsersChartProps = {
@ -22,7 +22,7 @@ export const MonthlyTotalUsersChart = ({ className, data }: MonthlyTotalUsersCha
return ( return (
<div className={cn('flex flex-col', className)}> <div className={cn('flex flex-col', className)}>
<div className="flex items-center px-4"> <div className="flex items-center px-4">
<h3 className="text-lg font-semibold">Monthly Total Users</h3> <h3 className="text-lg font-semibold">Total Users</h3>
</div> </div>
<div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow"> <div className="border-border mt-2.5 flex flex-1 items-center justify-center rounded-2xl border p-6 pl-2 pt-12 shadow-sm hover:shadow">

View File

@ -29,10 +29,7 @@ export function OpenPageTooltip() {
</svg> </svg>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>Active Subscriptions.</p>
August and earlier: Active subscribers. September and beyond: Numbers of active
subscriptions.
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View File

@ -86,6 +86,7 @@ export const SinglePlayerClient = () => {
data.fields.map((field, i) => ({ data.fields.map((field, i) => ({
id: i, id: i,
documentId: -1, documentId: -1,
templateId: null,
recipientId: -1, recipientId: -1,
type: field.type, type: field.type,
page: field.pageNumber, page: field.pageNumber,
@ -148,6 +149,7 @@ export const SinglePlayerClient = () => {
const placeholderRecipient: Recipient = { const placeholderRecipient: Recipient = {
id: -1, id: -1,
documentId: -1, documentId: -1,
templateId: null,
email: '', email: '',
name: '', name: '',
token: '', token: '',

View File

@ -1,160 +0,0 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Info } from 'lucide-react';
import { usePlausible } from 'next-plausible';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher';
import { FormErrorMessage } from '../form/form-error-message';
export const ZClaimPlanDialogFormSchema = z.object({
name: z.string().trim().min(3, { message: 'Please enter a valid name.' }),
email: z.string().email(),
});
export type TClaimPlanDialogFormSchema = z.infer<typeof ZClaimPlanDialogFormSchema>;
export type ClaimPlanDialogProps = {
className?: string;
planId: string;
children: React.ReactNode;
};
export const ClaimPlanDialog = ({ className, planId, children }: ClaimPlanDialogProps) => {
const params = useSearchParams();
const analytics = useAnalytics();
const event = usePlausible();
const { toast } = useToast();
const [open, setOpen] = useState(() => params?.get('cancelled') === 'true');
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<TClaimPlanDialogFormSchema>({
defaultValues: {
name: params?.get('name') ?? '',
email: params?.get('email') ?? '',
},
resolver: zodResolver(ZClaimPlanDialogFormSchema),
});
const onFormSubmit = async ({ name, email }: TClaimPlanDialogFormSchema) => {
try {
const delay = new Promise<void>((resolve) => {
setTimeout(resolve, 1000);
});
const [redirectUrl] = await Promise.all([
claimPlan({ name, email, planId, signatureText: name, signatureDataUrl: null }),
delay,
]);
event('claim-plan-pricing');
analytics.capture('Marketing: Claim plan', { planId, email });
window.location.href = redirectUrl;
} catch (error) {
event('claim-plan-failed');
analytics.capture('Marketing: Claim plan failure', { planId, email });
toast({
title: 'Something went wrong',
description: error instanceof Error ? error.message : 'Please try again later.',
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isSubmitting && !open) {
reset();
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && setOpen(value)}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Claim your plan</DialogTitle>
<DialogDescription className="mt-4">
We're almost there! Please enter your email address and name to claim your plan.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}>
<fieldset disabled={isSubmitting} className={cn('flex flex-col gap-y-4', className)}>
{params?.get('cancelled') === 'true' && (
<div className="rounded-lg border border-yellow-400 bg-yellow-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<Info className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<p className="text-sm leading-5 text-yellow-700">
You have cancelled the payment process. If you didn't mean to do this, please
try again.
</p>
</div>
</div>
</div>
)}
<div>
<Label className="text-muted-foreground">Name</Label>
<Input type="text" className="mt-2" {...register('name')} autoFocus />
<FormErrorMessage className="mt-1" error={errors.name} />
</div>
<div>
<Label className="text-muted-foreground">Email</Label>
<Input type="email" className="mt-2" {...register('email')} />
<FormErrorMessage className="mt-1" error={errors.email} />
</div>
<Button type="submit" size="lg" loading={isSubmitting}>
Claim the early adopters Plan (
{/* eslint-disable-next-line turbo/no-undeclared-env-vars */}
{planId === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
? 'Monthly'
: 'Yearly'}
)
</Button>
</fieldset>
</form>
</DialogContent>
</Dialog>
);
};

View File

@ -39,7 +39,7 @@ export const Footer = ({ className, ...props }: FooterProps) => {
return ( return (
<div className={cn('border-t py-12', className)} {...props}> <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 className="mx-auto flex w-full max-w-screen-xl flex-wrap items-start justify-between gap-8 px-8">
<div> <div className="flex-shrink-0">
<Link href="/"> <Link href="/">
<Image <Image
src={LogoImage} src={LogoImage}
@ -64,13 +64,13 @@ export const Footer = ({ className, ...props }: FooterProps) => {
</div> </div>
</div> </div>
<div className="grid max-w-xs flex-1 grid-cols-2 gap-x-4 gap-y-2"> <div className="grid w-full max-w-sm grid-cols-2 gap-x-4 gap-y-2 md:w-auto md:gap-x-8">
{FOOTER_LINKS.map((link, index) => ( {FOOTER_LINKS.map((link, index) => (
<Link <Link
key={index} key={index}
href={link.href} href={link.href}
target={link.target} target={link.target}
className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 text-sm" className="text-muted-foreground hover:text-muted-foreground/80 flex-shrink-0 break-words text-sm"
> >
{link.text} {link.text}
</Link> </Link>

View File

@ -1,9 +1,9 @@
'use client'; 'use client';
import { HTMLAttributes, useState } from 'react'; import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { usePlausible } from 'next-plausible'; import { usePlausible } from 'next-plausible';
@ -16,14 +16,9 @@ export type PricingTableProps = HTMLAttributes<HTMLDivElement>;
const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar'; const SELECTED_PLAN_BAR_LAYOUT_ID = 'selected-plan-bar';
export const PricingTable = ({ className, ...props }: PricingTableProps) => { export const PricingTable = ({ className, ...props }: PricingTableProps) => {
const params = useSearchParams();
const event = usePlausible(); const event = usePlausible();
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(() => const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY');
params?.get('planId') === process.env.NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
? 'YEARLY'
: 'MONTHLY',
);
return ( return (
<div className={cn('', className)} {...props}> <div className={cn('', className)} {...props}>

View File

@ -6,8 +6,9 @@ import Link from 'next/link';
import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { DocumentStatus, Signature } from '@documenso/prisma/client'; import type { Signature } from '@documenso/prisma/client';
import { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient'; import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithRecipient } from '@documenso/prisma/types/document-with-recipient';
import DocumentDialog from '@documenso/ui/components/document/document-dialog'; import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';

View File

@ -27,6 +27,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { claimPlan } from '~/api/claim-plan/fetcher'; import { claimPlan } from '~/api/claim-plan/fetcher';
import { STEP } from '../constants';
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
const ZWidgetFormSchema = z const ZWidgetFormSchema = z
@ -49,13 +50,16 @@ const ZWidgetFormSchema = z
export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>; export type TWidgetFormSchema = z.infer<typeof ZWidgetFormSchema>;
type StepKeys = keyof typeof STEP;
type StepValues = (typeof STEP)[StepKeys];
export type WidgetProps = HTMLAttributes<HTMLDivElement>; export type WidgetProps = HTMLAttributes<HTMLDivElement>;
export const Widget = ({ className, children, ...props }: WidgetProps) => { export const Widget = ({ className, children, ...props }: WidgetProps) => {
const { toast } = useToast(); const { toast } = useToast();
const event = usePlausible(); const event = usePlausible();
const [step, setStep] = useState<'EMAIL' | 'NAME' | 'SIGN'>('EMAIL'); const [step, setStep] = useState<StepValues>(STEP.EMAIL);
const [showSigningDialog, setShowSigningDialog] = useState(false); const [showSigningDialog, setShowSigningDialog] = useState(false);
const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null); const [draftSignatureDataUrl, setDraftSignatureDataUrl] = useState<string | null>(null);
@ -82,28 +86,28 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
const signatureText = watch('signatureText'); const signatureText = watch('signatureText');
const stepsRemaining = useMemo(() => { const stepsRemaining = useMemo(() => {
if (step === 'NAME') { if (step === STEP.NAME) {
return 2; return 2;
} }
if (step === 'SIGN') { if (step === STEP.EMAIL) {
return 1; return 3;
} }
return 3; return 1;
}, [step]); }, [step]);
const onNextStepClick = () => { const onNextStepClick = () => {
if (step === 'EMAIL') { if (step === STEP.EMAIL) {
setStep('NAME'); setStep(STEP.NAME);
setTimeout(() => { setTimeout(() => {
document.querySelector<HTMLElement>('#name')?.focus(); document.querySelector<HTMLElement>('#name')?.focus();
}, 0); }, 0);
} }
if (step === 'NAME') { if (step === STEP.NAME) {
setStep('SIGN'); setStep(STEP.SIGN);
setTimeout(() => { setTimeout(() => {
document.querySelector<HTMLElement>('#signatureText')?.focus(); document.querySelector<HTMLElement>('#signatureText')?.focus();
@ -144,19 +148,19 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
const claimPlanInput = signatureDataUrl const claimPlanInput = signatureDataUrl
? { ? {
name, name,
email, email,
planId, planId,
signatureDataUrl: signatureDataUrl, signatureDataUrl: signatureDataUrl,
signatureText: null, signatureText: null,
} }
: { : {
name, name,
email, email,
planId, planId,
signatureDataUrl: null, signatureDataUrl: null,
signatureText: signatureText ?? '', signatureText: signatureText ?? '',
}; };
const [result] = await Promise.all([claimPlan(claimPlanInput), delay]); const [result] = await Promise.all([claimPlan(claimPlanInput), delay]);
@ -227,7 +231,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
type="button" type="button"
className="bg-primary h-full w-14 rounded" className="bg-primary h-full w-14 rounded"
disabled={!field.value || !!errors.email?.message} disabled={!field.value || !!errors.email?.message}
onClick={() => step === 'EMAIL' && onNextStepClick()} onClick={() => step === STEP.EMAIL && onNextStepClick()}
> >
Next Next
</Button> </Button>
@ -239,7 +243,7 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
<FormErrorMessage error={errors.email} className="mt-1" /> <FormErrorMessage error={errors.email} className="mt-1" />
</motion.div> </motion.div>
{(step === 'NAME' || step === 'SIGN') && ( {(step === STEP.NAME || step === STEP.SIGN) && (
<motion.div <motion.div
key="name" key="name"
className="mt-4" className="mt-4"
@ -389,10 +393,11 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
</DialogHeader> </DialogHeader>
<DialogDescription> <DialogDescription>
By signing you signal your support of Documenso's mission in a <br></br> By signing you signal your support of Documenso's mission in a <br />
<strong>non-legally binding, but heartfelt way</strong>. <br></br> <strong>non-legally binding, but heartfelt way</strong>. <br />
<br></br>You also unlock the option to purchase the early supporter plan including <br />
everything we build this year for fixed price. You also unlock the option to purchase the early supporter plan including everything we
build this year for fixed price.
</DialogDescription> </DialogDescription>
<SignaturePad <SignaturePad

View File

@ -0,0 +1,5 @@
export const STEP = {
EMAIL: 'EMAIL',
NAME: 'NAME',
SIGN: 'SIGN',
} as const;

View File

@ -1,4 +1,4 @@
import { NextApiRequest, NextApiResponse } from 'next'; import type { NextApiRequest, NextApiResponse } from 'next';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { buffer } from 'micro'; import { buffer } from 'micro';
@ -6,7 +6,8 @@ import { buffer } from 'micro';
import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf'; import { insertImageInPDF } from '@documenso/lib/server-only/pdf/insert-image-in-pdf';
import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf'; import { insertTextInPDF } from '@documenso/lib/server-only/pdf/insert-text-in-pdf';
import { redis } from '@documenso/lib/server-only/redis'; import { redis } from '@documenso/lib/server-only/redis';
import { Stripe, stripe } from '@documenso/lib/server-only/stripe'; import type { Stripe } from '@documenso/lib/server-only/stripe';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFile } from '@documenso/lib/universal/upload/get-file'; import { getFile } from '@documenso/lib/universal/upload/get-file';
import { updateFile } from '@documenso/lib/universal/upload/update-file'; import { updateFile } from '@documenso/lib/universal/upload/update-file';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';

View File

@ -42,7 +42,7 @@
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"sharp": "0.32.5", "sharp": "0.33.1",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"typescript": "5.2.2", "typescript": "5.2.2",
"uqr": "^0.1.2", "uqr": "^0.1.2",

View File

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

View File

@ -9,7 +9,6 @@ import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema'; import { ZUpdateProfileMutationByAdminSchema } from '@documenso/trpc/server/admin-router/schema';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Combobox } from '@documenso/ui/primitives/combobox';
import { import {
Form, Form,
FormControl, FormControl,
@ -19,6 +18,7 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multiselect-combobox';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true }); const ZUserFormSchema = ZUpdateProfileMutationByAdminSchema.omit({ id: true });
@ -117,7 +117,7 @@ export default function UserPage({ params }: { params: { id: number } }) {
<fieldset className="flex flex-col gap-2"> <fieldset className="flex flex-col gap-2">
<FormLabel className="text-muted-foreground">Roles</FormLabel> <FormLabel className="text-muted-foreground">Roles</FormLabel>
<FormControl> <FormControl>
<Combobox <MultiSelectCombobox
listValues={roles} listValues={roles}
onChange={(values: string[]) => onChange(values)} onChange={(values: string[]) => onChange(values)}
/> />

View File

@ -8,7 +8,7 @@ import { Edit, Loader } from 'lucide-react';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
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 { Document, Role, Subscription } from '@documenso/prisma/client'; import type { Document, Role, Subscription } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
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';
@ -19,7 +19,7 @@ type UserData = {
name: string | null; name: string | null;
email: string; email: string;
roles: Role[]; roles: Role[];
Subscription?: SubscriptionLite | null; Subscription?: SubscriptionLite[] | null;
Document: DocumentLite[]; Document: DocumentLite[];
}; };
@ -35,9 +35,16 @@ type UsersDataTableProps = {
totalPages: number; totalPages: number;
perPage: number; perPage: number;
page: number; page: number;
individualPriceIds: string[];
}; };
export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTableProps) => { export const UsersDataTable = ({
users,
totalPages,
perPage,
page,
individualPriceIds,
}: UsersDataTableProps) => {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams(); const updateSearchParams = useUpdateSearchParams();
const [searchString, setSearchString] = useState(''); const [searchString, setSearchString] = useState('');
@ -100,7 +107,13 @@ export const UsersDataTable = ({ users, totalPages, perPage, page }: UsersDataTa
{ {
header: 'Subscription', header: 'Subscription',
accessorKey: 'subscription', accessorKey: 'subscription',
cell: ({ row }) => row.original.Subscription?.status ?? 'NONE', cell: ({ row }) => {
const foundIndividualSubscription = (row.original.Subscription ?? []).find((sub) =>
individualPriceIds.includes(sub.priceId),
);
return foundIndividualSubscription?.status ?? 'NONE';
},
}, },
{ {
header: 'Documents', header: 'Documents',

View File

@ -1,3 +1,5 @@
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { UsersDataTable } from './data-table-users'; import { UsersDataTable } from './data-table-users';
import { search } from './fetch-users.actions'; import { search } from './fetch-users.actions';
@ -14,12 +16,23 @@ export default async function AdminManageUsers({ searchParams = {} }: AdminManag
const perPage = Number(searchParams.perPage) || 10; const perPage = Number(searchParams.perPage) || 10;
const searchString = searchParams.search || ''; const searchString = searchParams.search || '';
const { users, totalPages } = await search(searchString, page, perPage); const [{ users, totalPages }, individualPrices] = await Promise.all([
search(searchString, page, perPage),
getPricesByType('individual'),
]);
const individualPriceIds = individualPrices.map((price) => price.id);
return ( return (
<div> <div>
<h2 className="text-4xl font-semibold">Manage users</h2> <h2 className="text-4xl font-semibold">Manage users</h2>
<UsersDataTable users={users} totalPages={totalPages} page={page} perPage={perPage} /> <UsersDataTable
users={users}
individualPriceIds={individualPriceIds}
totalPages={totalPages}
page={page}
perPage={perPage}
/>
</div> </div>
); );
} }

View File

@ -4,8 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client'; import type { DocumentData, Field, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -145,14 +145,16 @@ export const EditDocumentForm = ({
}; };
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => { const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
const { subject, message } = data.email; const { subject, message, timezone, dateFormat } = data.meta;
try { try {
await sendDocument({ await sendDocument({
documentId: document.id, documentId: document.id,
email: { meta: {
subject, subject,
message, message,
timezone,
dateFormat,
}, },
}); });

View File

@ -99,6 +99,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
}; };
const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED'); const nonSignedRecipients = row.Recipient.filter((item) => item.signingStatus !== 'SIGNED');
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@ -164,6 +165,7 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DeleteDocumentDialog <DeleteDocumentDialog
id={row.id} id={row.id}
status={row.status} status={row.status}
documentTitle={row.title}
open={isDeleteDialogOpen} open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen} onOpenChange={setDeleteDialogOpen}
/> />

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -21,6 +21,7 @@ type DeleteDraftDocumentDialogProps = {
open: boolean; open: boolean;
onOpenChange: (_open: boolean) => void; onOpenChange: (_open: boolean) => void;
status: DocumentStatus; status: DocumentStatus;
documentTitle: string;
}; };
export const DeleteDocumentDialog = ({ export const DeleteDocumentDialog = ({
@ -28,6 +29,7 @@ export const DeleteDocumentDialog = ({
open, open,
onOpenChange, onOpenChange,
status, status,
documentTitle,
}: DeleteDraftDocumentDialogProps) => { }: DeleteDraftDocumentDialogProps) => {
const router = useRouter(); const router = useRouter();
@ -42,7 +44,7 @@ export const DeleteDocumentDialog = ({
toast({ toast({
title: 'Document deleted', title: 'Document deleted',
description: 'Your document has been successfully deleted.', description: `"${documentTitle}" has been successfully deleted`,
duration: 5000, duration: 5000,
}); });
@ -50,6 +52,13 @@ export const DeleteDocumentDialog = ({
}, },
}); });
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onDelete = async () => { const onDelete = async () => {
try { try {
await deleteDocument({ id, status }); await deleteDocument({ id, status });
@ -72,7 +81,7 @@ export const DeleteDocumentDialog = ({
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}> <Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Do you want to delete this document?</DialogTitle> <DialogTitle>Are you sure you want to delete "{documentTitle}"?</DialogTitle>
<DialogDescription> <DialogDescription>
Please note that this action is irreversible. Once confirmed, your document will be Please note that this action is irreversible. Once confirmed, your document will be
@ -81,7 +90,7 @@ export const DeleteDocumentDialog = ({
</DialogHeader> </DialogHeader>
{status !== DocumentStatus.DRAFT && ( {status !== DocumentStatus.DRAFT && (
<div className="mt-8"> <div className="mt-4">
<Input <Input
type="text" type="text"
value={inputValue} value={inputValue}

View File

@ -41,6 +41,7 @@ export const DuplicateDocumentDialog = ({
trpcReact.document.duplicateDocument.useMutation({ trpcReact.document.duplicateDocument.useMutation({
onSuccess: (newId) => { onSuccess: (newId) => {
router.push(`/documents/${newId}`); router.push(`/documents/${newId}`);
toast({ toast({
title: 'Document Duplicated', title: 'Document Duplicated',
description: 'Your document has been successfully duplicated.', description: 'Your document has been successfully duplicated.',

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@ -25,6 +25,7 @@ export type UploadDocumentProps = {
export const UploadDocument = ({ className }: UploadDocumentProps) => { export const UploadDocument = ({ className }: UploadDocumentProps) => {
const router = useRouter(); const router = useRouter();
const analytics = useAnalytics(); const analytics = useAnalytics();
const { data: session } = useSession(); const { data: session } = useSession();
const { toast } = useToast(); const { toast } = useToast();
@ -35,6 +36,16 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation(); const { mutateAsync: createDocument } = trpc.document.createDocument.useMutation();
const disabledMessage = useMemo(() => {
if (remaining.documents === 0) {
return 'You have reached your document limit.';
}
if (!session?.user.emailVerified) {
return 'Verify your email to upload documents.';
}
}, [remaining.documents, session?.user.emailVerified]);
const onFileDrop = async (file: File) => { const onFileDrop = async (file: File) => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -90,6 +101,7 @@ export const UploadDocument = ({ className }: UploadDocumentProps) => {
<DocumentDropzone <DocumentDropzone
className="min-h-[40vh]" className="min-h-[40vh]"
disabled={remaining.documents === 0 || !session?.user.emailVerified} disabled={remaining.documents === 0 || !session?.user.emailVerified}
disabledMessage={disabledMessage}
onDrop={onFileDrop} onDrop={onFileDrop}
/> />

View File

@ -4,7 +4,7 @@ import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; import type { PriceIntervals } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted'; import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price'; import { toHumanPrice } from '@documenso/lib/universal/stripe/to-human-price';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';

View File

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

View File

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

View File

@ -2,12 +2,15 @@ import { redirect } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval'; import { getPricesByInterval } from '@documenso/ee/server-only/stripe/get-prices-by-interval';
import { getPricesByType } from '@documenso/ee/server-only/stripe/get-prices-by-type';
import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id'; import { getProductByPriceId } from '@documenso/ee/server-only/stripe/get-product-by-price-id';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-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 { getServerComponentFlag } from '@documenso/lib/server-only/feature-flags/get-server-component-feature-flag';
import type { 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 { getSubscriptionsByUserId } from '@documenso/lib/server-only/subscription/get-subscriptions-by-user-id';
import { SubscriptionStatus } from '@documenso/prisma/client';
import { LocaleDate } from '~/components/formatter/locale-date'; import { LocaleDate } from '~/components/formatter/locale-date';
@ -15,7 +18,7 @@ import { BillingPlans } from './billing-plans';
import { BillingPortalButton } from './billing-portal-button'; import { BillingPortalButton } from './billing-portal-button';
export default async function BillingSettingsPage() { export default async function BillingSettingsPage() {
const { user } = await getRequiredServerComponentSession(); let { user } = await getRequiredServerComponentSession();
const isBillingEnabled = await getServerComponentFlag('app_billing'); const isBillingEnabled = await getServerComponentFlag('app_billing');
@ -24,20 +27,36 @@ export default async function BillingSettingsPage() {
redirect('/settings/profile'); redirect('/settings/profile');
} }
const [subscription, prices] = await Promise.all([ if (!user.customerId) {
getSubscriptionByUserId({ userId: user.id }), user = await getStripeCustomerByUser(user).then((result) => result.user);
getPricesByInterval(), }
const [subscriptions, prices, individualPrices] = await Promise.all([
getSubscriptionsByUserId({ userId: user.id }),
getPricesByInterval({ type: 'individual' }),
getPricesByType('individual'),
]); ]);
const individualPriceIds = individualPrices.map(({ id }) => id);
let subscriptionProduct: Stripe.Product | null = null; let subscriptionProduct: Stripe.Product | null = null;
const individualUserSubscriptions = subscriptions.filter(({ priceId }) =>
individualPriceIds.includes(priceId),
);
const subscription =
individualUserSubscriptions.find(({ status }) => status === SubscriptionStatus.ACTIVE) ??
individualUserSubscriptions[0];
if (subscription?.priceId) { if (subscription?.priceId) {
subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch( subscriptionProduct = await getProductByPriceId({ priceId: subscription.priceId }).catch(
() => null, () => null,
); );
} }
const isMissingOrInactiveOrFreePlan = !subscription || subscription.status === 'INACTIVE'; const isMissingOrInactiveOrFreePlan =
!subscription || subscription.status === SubscriptionStatus.INACTIVE;
return ( return (
<div> <div>

View File

@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { DocumentData, Field, Recipient, Template, User } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
DocumentFlowFormContainer,
DocumentFlowFormContainerHeader,
} from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EditTemplateFormProps = {
className?: string;
user: User;
template: Template;
recipients: Recipient[];
fields: Field[];
documentData: DocumentData;
};
type EditTemplateStep = 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['signers', 'fields'];
export const EditTemplateForm = ({
className,
template,
recipients,
fields,
user: _user,
documentData,
}: EditTemplateFormProps) => {
const { toast } = useToast();
const router = useRouter();
const [step, setStep] = useState<EditTemplateStep>('signers');
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
signers: {
title: 'Add Placeholders',
description: 'Add all relevant placeholders for each recipient.',
stepIndex: 1,
},
fields: {
title: 'Add Fields',
description: 'Add all relevant fields for each recipient.',
stepIndex: 2,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation();
const { mutateAsync: addTemplateSigners } = trpc.recipient.addTemplateSigners.useMutation();
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await addTemplateSigners({
templateId: template.id,
signers: data.signers,
});
router.refresh();
setStep('fields');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
toast({
title: 'Template saved',
description: 'Your templates has been saved successfully.',
duration: 5000,
});
router.push('/templates');
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while adding signers.',
variant: 'destructive',
});
}
};
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer key={documentData.id} documentData={documentData} />
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<DocumentFlowFormContainerHeader
title={currentDocumentFlow.title}
description={currentDocumentFlow.description}
/>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
onSubmit={onAddTemplatePlaceholderFormSubmit}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

@ -0,0 +1,81 @@
import React from 'react';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getFieldsForTemplate } from '@documenso/lib/server-only/field/get-fields-for-template';
import { getRecipientsForTemplate } from '@documenso/lib/server-only/recipient/get-recipients-for-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { TemplateType } from '~/components/formatter/template-type';
import { EditTemplateForm } from './edit-template';
export type TemplatePageProps = {
params: {
id: string;
};
};
export default async function TemplatePage({ params }: TemplatePageProps) {
const { id } = params;
const templateId = Number(id);
if (!templateId || Number.isNaN(templateId)) {
redirect('/documents');
}
const { user } = await getRequiredServerComponentSession();
const template = await getTemplateById({
id: templateId,
userId: user.id,
}).catch(() => null);
if (!template || !template.templateDocumentData) {
redirect('/documents');
}
const { templateDocumentData } = template;
const [templateRecipients, templateFields] = await Promise.all([
getRecipientsForTemplate({
templateId,
userId: user.id,
}),
getFieldsForTemplate({
templateId,
userId: user.id,
}),
]);
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
Templates
</Link>
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
{template.title}
</h1>
<div className="mt-2.5 flex items-center gap-x-6">
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
</div>
<EditTemplateForm
className="mt-8"
template={template}
user={user}
recipients={templateRecipients}
fields={templateFields}
documentData={templateDocumentData}
/>
</div>
);
}

View File

@ -0,0 +1,79 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
import { useSession } from 'next-auth/react';
import type { Template } from '@documenso/prisma/client';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { DeleteTemplateDialog } from './delete-template-dialog';
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
export type DataTableActionDropdownProps = {
row: Template;
};
export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) => {
const { data: session } = useSession();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
if (!session) {
return null;
}
const isOwner = row.userId === session.user.id;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!isOwner} asChild>
<Link href={`/templates/${row.id}`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem disabled={!isOwner} onClick={async () => onDuplicateButtonClick(row.id)}> */}
<DropdownMenuItem disabled={!isOwner} onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled={!isOwner} onClick={() => setDeleteDialogOpen(true)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
<DuplicateTemplateDialog
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>
<DeleteTemplateDialog
id={row.id}
open={isDeleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
</DropdownMenu>
);
};

View File

@ -0,0 +1,138 @@
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { Loader, Plus } from 'lucide-react';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import type { Template } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { LocaleDate } from '~/components/formatter/locale-date';
import { TemplateType } from '~/components/formatter/template-type';
import { DataTableActionDropdown } from './data-table-action-dropdown';
import { DataTableTitle } from './data-table-title';
type TemplatesDataTableProps = {
templates: Template[];
perPage: number;
page: number;
totalPages: number;
};
export const TemplatesDataTable = ({
templates,
perPage,
page,
totalPages,
}: TemplatesDataTableProps) => {
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const router = useRouter();
const { toast } = useToast();
const [loadingStates, setLoadingStates] = useState<{ [key: string]: boolean }>({});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const onPaginationChange = (page: number, perPage: number) => {
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
const onUseButtonClick = async (templateId: number) => {
try {
const { id } = await createDocumentFromTemplate({
templateId,
});
toast({
title: 'Document created',
description: 'Your document has been created from the template successfully.',
duration: 5000,
});
router.push(`/documents/${id}`);
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while creating document from template.',
variant: 'destructive',
});
}
};
return (
<div className="relative">
<DataTable
columns={[
{
header: 'Created',
accessorKey: 'createdAt',
cell: ({ row }) => <LocaleDate date={row.original.createdAt} />,
},
{
header: 'Title',
cell: ({ row }) => <DataTableTitle row={row.original} />,
},
{
header: 'Type',
accessorKey: 'type',
cell: ({ row }) => <TemplateType type={row.original.type} />,
},
{
header: 'Actions',
accessorKey: 'actions',
cell: ({ row }) => {
const isRowLoading = loadingStates[row.original.id];
return (
<div className="flex items-center gap-x-4">
<Button
disabled={isRowLoading}
loading={isRowLoading}
onClick={async () => {
setLoadingStates((prev) => ({ ...prev, [row.original.id]: true }));
await onUseButtonClick(row.original.id);
setLoadingStates((prev) => ({ ...prev, [row.original.id]: false }));
}}
>
{!isRowLoading && <Plus className="-ml-1 mr-2 h-4 w-4" />}
Use Template
</Button>
<DataTableActionDropdown row={row.original} />
</div>
);
},
},
]}
data={templates}
perPage={perPage}
currentPage={page}
totalPages={totalPages}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,26 @@
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import { Template } from '@documenso/prisma/client';
export type DataTableTitleProps = {
row: Template;
};
export const DataTableTitle = ({ row }: DataTableTitleProps) => {
const { data: session } = useSession();
if (!session) {
return null;
}
return (
<Link
href={`/templates/${row.id}`}
className="block max-w-[10rem] cursor-pointer truncate font-medium hover:underline md:max-w-[20rem]"
>
{row.title}
</Link>
);
};

View File

@ -0,0 +1,84 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DeleteTemplateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DeleteTemplateDialog = ({ id, open, onOpenChange }: DeleteTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isLoading } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template deleted',
description: 'Your template has been successfully deleted.',
duration: 5000,
});
onOpenChange(false);
},
});
const onDeleteTemplate = async () => {
try {
await deleteTemplate({ id });
} catch {
toast({
title: 'Something went wrong',
description: 'This template could not be deleted at this time. Please try again.',
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to delete this template?</DialogTitle>
<DialogDescription>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDeleteTemplate} className="flex-1">
Delete
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,87 @@
import { useRouter } from 'next/navigation';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DuplicateTemplateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DuplicateTemplateDialog = ({
id,
open,
onOpenChange,
}: DuplicateTemplateDialogProps) => {
const router = useRouter();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isLoading } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
router.refresh();
toast({
title: 'Template duplicated',
description: 'Your template has been duplicated successfully.',
duration: 5000,
});
onOpenChange(false);
},
});
const onDuplicate = async () => {
try {
await duplicateTemplate({
templateId: id,
});
} catch (err) {
toast({
title: 'Error',
description: 'An error occurred while duplicating template.',
variant: 'destructive',
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Do you want to duplicate this template?</DialogTitle>
<DialogDescription className="pt-2">Your template will be duplicated.</DialogDescription>
</DialogHeader>
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="button" loading={isLoading} onClick={onDuplicate} className="flex-1">
Duplicate
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,17 @@
import { Bird } from 'lucide-react';
export const EmptyTemplateState = () => {
return (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">We're all empty</h3>
<p className="mt-2 max-w-[50ch]">
You have not yet created any templates. To create a template please upload one.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,228 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { FilePlus, X } from 'lucide-react';
import { useSession } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { base64 } from '@documenso/lib/universal/base64';
import { putFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZCreateTemplateFormSchema = z.object({
name: z.string(),
});
type TCreateTemplateFormSchema = z.infer<typeof ZCreateTemplateFormSchema>;
export const NewTemplateDialog = () => {
const router = useRouter();
const { data: session } = useSession();
const { toast } = useToast();
const form = useForm<TCreateTemplateFormSchema>({
defaultValues: {
name: '',
},
resolver: zodResolver(ZCreateTemplateFormSchema),
});
const { mutateAsync: createTemplate, isLoading: isCreatingTemplate } =
trpc.template.createTemplate.useMutation();
const [showNewTemplateDialog, setShowNewTemplateDialog] = useState(false);
const [uploadedFile, setUploadedFile] = useState<{ file: File; fileBase64: string } | null>();
const onFileDrop = async (file: File) => {
try {
const arrayBuffer = await file.arrayBuffer();
const base64String = base64.encode(new Uint8Array(arrayBuffer));
setUploadedFile({
file,
fileBase64: `data:application/pdf;base64,${base64String}`,
});
if (!form.getValues('name')) {
form.setValue('name', file.name);
}
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const onSubmit = async (values: TCreateTemplateFormSchema) => {
if (!uploadedFile) {
return;
}
const file: File = uploadedFile.file;
try {
const { type, data } = await putFile(file);
const { id: templateDocumentDataId } = await createDocumentData({
type,
data,
});
const { id } = await createTemplate({
title: values.name ? values.name : file.name,
templateDocumentDataId,
});
toast({
title: 'Template document uploaded',
description:
'Your document has been uploaded successfully. You will be redirected to the template page.',
duration: 5000,
});
setShowNewTemplateDialog(false);
void router.push(`/templates/${id}`);
} catch {
toast({
title: 'Something went wrong',
description: 'Please try again later.',
variant: 'destructive',
});
}
};
const resetForm = () => {
if (form.getValues('name') === uploadedFile?.file.name) {
form.reset();
}
setUploadedFile(null);
};
useEffect(() => {
if (!showNewTemplateDialog) {
form.reset();
}
}, [form, showNewTemplateDialog]);
return (
<Dialog open={showNewTemplateDialog} onOpenChange={setShowNewTemplateDialog}>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!session?.user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
New Template
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl">
<DialogHeader>
<DialogTitle className="mb-4">New Template</DialogTitle>
</DialogHeader>
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name your template</FormLabel>
<FormControl>
<Input id="email" type="text" className="bg-background mt-1.5" {...field} />
</FormControl>
<FormDescription>
<span className="text-muted-foreground text-xs">
Leave this empty if you would like to use your document's name for the
template
</span>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
<Label htmlFor="template">Upload a Document</Label>
<div className="my-3">
{uploadedFile ? (
<Card gradient className="h-[40vh]">
<CardContent className="flex h-full flex-col items-center justify-center p-2">
<button
onClick={() => resetForm()}
title="Remove Template"
className="text-muted-foreground absolute right-2.5 top-2.5 rounded-sm opacity-60 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none"
>
<X className="h-6 w-6" />
<span className="sr-only">Remove Template</span>
</button>
<div className="border-muted-foreground/20 group-hover:border-documenso/80 dark:bg-muted/80 z-10 flex aspect-[3/4] w-24 flex-col gap-y-1 rounded-lg border bg-white/80 px-2 py-4 backdrop-blur-sm">
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-5/6 rounded-[2px]" />
<div className="bg-muted-foreground/20 group-hover:bg-documenso h-2 w-full rounded-[2px]" />
</div>
<p className="group-hover:text-foreground text-muted-foreground mt-4 font-medium">
Uploaded Document
</p>
<span className="text-muted-foreground/80 mt-1 text-sm">
{uploadedFile.file.name}
</span>
</CardContent>
</Card>
) : (
<DocumentDropzone
className="mt-1.5 h-[40vh]"
onDrop={onFileDrop}
type="template"
/>
)}
</div>
</div>
<div className="flex w-full justify-end">
<Button loading={isCreatingTemplate} type="submit">
Create Template
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,52 @@
import React from 'react';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTemplates } from '@documenso/lib/server-only/template/get-templates';
import { TemplatesDataTable } from './data-table-templates';
import { EmptyTemplateState } from './empty-state';
import { NewTemplateDialog } from './new-template-dialog';
type TemplatesPageProps = {
searchParams?: {
page?: number;
perPage?: number;
};
};
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
const { user } = await getRequiredServerComponentSession();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;
const { templates, totalPages } = await getTemplates({
userId: user.id,
page: page,
perPage: perPage,
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<div className="flex items-baseline justify-between">
<h1 className="mb-5 mt-2 truncate text-2xl font-semibold md:text-3xl">Templates</h1>
<div>
<NewTemplateDialog />
</div>
</div>
<div className="relative">
{templates.length > 0 ? (
<TemplatesDataTable
templates={templates}
page={page}
perPage={perPage}
totalPages={totalPages}
/>
) : (
<EmptyTemplateState />
)}
</div>
</div>
);
}

View File

@ -15,6 +15,8 @@ import { DocumentDownloadButton } from '@documenso/ui/components/document/docume
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { truncateTitle } from '~/helpers/truncate-title';
export type CompletedSigningPageProps = { export type CompletedSigningPageProps = {
params: { params: {
token?: string; token?: string;
@ -36,6 +38,8 @@ export default async function CompletedSigningPage({
return notFound(); return notFound();
} }
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document; const { documentData } = document;
const [fields, recipient] = await Promise.all([ const [fields, recipient] = await Promise.all([
@ -89,7 +93,7 @@ export default async function CompletedSigningPage({
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl"> <h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed You have signed
<span className="mt-1.5 block">"{document.title}"</span> <span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2> </h2>
{match({ status: document.status, deletedAt: document.deletedAt }) {match({ status: document.status, deletedAt: document.deletedAt })

View File

@ -6,8 +6,13 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client'; import {
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; DEFAULT_DOCUMENT_DATE_FORMAT,
convertToLocalSystemFormat,
} from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import type { Recipient } from '@documenso/prisma/client';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -16,9 +21,16 @@ import { SigningFieldContainer } from './signing-field-container';
export type DateFieldProps = { export type DateFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
recipient: Recipient; recipient: Recipient;
dateFormat?: string | null;
timezone?: string | null;
}; };
export const DateField = ({ field, recipient }: DateFieldProps) => { export const DateField = ({
field,
recipient,
dateFormat = DEFAULT_DOCUMENT_DATE_FORMAT,
timezone = DEFAULT_DOCUMENT_TIME_ZONE,
}: DateFieldProps) => {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
@ -35,12 +47,18 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending;
const localDateString = convertToLocalSystemFormat(field.customText, dateFormat, timezone);
const isDifferentTime = field.inserted && localDateString !== field.customText;
const tooltipText = `"${field.customText}" will appear on the document as it has a timezone of "${timezone}".`;
const onSign = async () => { const onSign = async () => {
try { try {
await signFieldWithToken({ await signFieldWithToken({
token: recipient.token, token: recipient.token,
fieldId: field.id, fieldId: field.id,
value: '', value: dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT,
}); });
startTransition(() => router.refresh()); startTransition(() => router.refresh());
@ -75,7 +93,13 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
}; };
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer
field={field}
onSign={onSign}
onRemove={onRemove}
type="Date"
tooltipText={isDifferentTime ? tooltipText : undefined}
>
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
@ -87,7 +111,7 @@ export const DateField = ({ field, recipient }: DateFieldProps) => {
)} )}
{field.inserted && ( {field.inserted && (
<p className="text-muted-foreground text-sm duration-200">{field.customText}</p> <p className="text-muted-foreground text-sm duration-200">{localDateString}</p>
)} )}
</SigningFieldContainer> </SigningFieldContainer>
); );

View File

@ -79,7 +79,7 @@ export const EmailField = ({ field, recipient }: EmailFieldProps) => {
}; };
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Email">
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@ -49,6 +49,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
const { data: session } = useSession(); const { data: session } = useSession();
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext(); const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
const { mutateAsync: completeDocumentWithToken } = const { mutateAsync: completeDocumentWithToken } =
@ -76,6 +77,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
return sortFieldsByPosition(fields.filter((field) => !field.inserted)); return sortFieldsByPosition(fields.filter((field) => !field.inserted));
}, [fields]); }, [fields]);
const fieldsValidated = () => {
setValidateUninsertedFields(true);
validateFieldsInserted(fields);
};
const onFormSubmit = async () => { const onFormSubmit = async () => {
setValidateUninsertedFields(true); setValidateUninsertedFields(true);
@ -120,7 +126,11 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
disabled={isSubmitting} disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')} className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')}
> >
<div className={cn('flex flex-1 flex-col')}> <div
className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
>
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3> <h3 className="text-foreground text-2xl font-semibold">Sign Document</h3>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
@ -232,6 +242,7 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
onSignatureComplete={handleSubmit(onFormSubmit)} onSignatureComplete={handleSubmit(onFormSubmit)}
document={document} document={document}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated}
/> />
</div> </div>
</div> </div>

View File

@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
@ -98,7 +98,7 @@ export const NameField = ({ field, recipient }: NameFieldProps) => {
}; };
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Name">
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@ -1,3 +1,5 @@
'use client';
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';

View File

@ -2,9 +2,12 @@ import { notFound, redirect } from 'next/navigation';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-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 { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getDocumentMetaByDocumentId } from '@documenso/lib/server-only/document/get-document-meta-by-document-id';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-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'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@ -14,6 +17,8 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { truncateTitle } from '~/helpers/truncate-title';
import { DateField } from './date-field'; import { DateField } from './date-field';
import { EmailField } from './email-field'; import { EmailField } from './email-field';
import { SigningForm } from './form'; import { SigningForm } from './form';
@ -42,10 +47,14 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
viewedDocument({ token }).catch(() => null), viewedDocument({ token }).catch(() => null),
]); ]);
const documentMeta = await getDocumentMetaByDocumentId({ id: document!.id }).catch(() => null);
if (!document || !document.documentData || !recipient) { if (!document || !document.documentData || !recipient) {
return notFound(); return notFound();
} }
const truncatedTitle = truncateTitle(document.title);
const { documentData } = document; const { documentData } = document;
const { user } = await getServerComponentSession(); const { user } = await getServerComponentSession();
@ -77,7 +86,7 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
> >
<div className="mx-auto w-full max-w-screen-xl"> <div className="mx-auto w-full max-w-screen-xl">
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}> <h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={document.title}>
{document.title} {truncatedTitle}
</h1> </h1>
<div className="mt-2.5 flex items-center gap-x-6"> <div className="mt-2.5 flex items-center gap-x-6">
@ -111,7 +120,13 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<NameField key={field.id} field={field} recipient={recipient} /> <NameField key={field.id} field={field} recipient={recipient} />
)) ))
.with(FieldType.DATE, () => ( .with(FieldType.DATE, () => (
<DateField key={field.id} field={field} recipient={recipient} /> <DateField
key={field.id}
field={field}
recipient={recipient}
dateFormat={documentMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
timezone={documentMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
/>
)) ))
.with(FieldType.EMAIL, () => ( .with(FieldType.EMAIL, () => (
<EmailField key={field.id} field={field} recipient={recipient} /> <EmailField key={field.id} field={field} recipient={recipient} />

View File

@ -9,10 +9,13 @@ import {
DialogTrigger, DialogTrigger,
} from '@documenso/ui/primitives/dialog'; } from '@documenso/ui/primitives/dialog';
import { truncateTitle } from '~/helpers/truncate-title';
export type SignDialogProps = { export type SignDialogProps = {
isSubmitting: boolean; isSubmitting: boolean;
document: Document; document: Document;
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>; onSignatureComplete: () => void | Promise<void>;
}; };
@ -20,30 +23,31 @@ export const SignDialog = ({
isSubmitting, isSubmitting,
document, document,
fields, fields,
fieldsValidated,
onSignatureComplete, onSignatureComplete,
}: SignDialogProps) => { }: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title);
const isComplete = fields.every((field) => field.inserted); const isComplete = fields.every((field) => field.inserted);
return ( return (
<Dialog open={showDialog} onOpenChange={setShowDialog}> <Dialog open={showDialog && isComplete} onOpenChange={setShowDialog}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
className="w-full" className="w-full"
type="button" type="button"
size="lg" size="lg"
disabled={!isComplete} onClick={fieldsValidated}
loading={isSubmitting} loading={isSubmitting}
> >
Complete {isComplete ? 'Complete' : 'Next field'}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<div className="text-center"> <div className="text-center">
<div className="text-xl font-semibold text-neutral-800">Sign Document</div> <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"> <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? You are about to finish signing "{truncatedTitle}". Are you sure?
</div> </div>
</div> </div>

View File

@ -127,7 +127,7 @@ export const SignatureField = ({ field, recipient }: SignatureFieldProps) => {
}; };
return ( return (
<SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove}> <SigningFieldContainer field={field} onSign={onSign} onRemove={onRemove} type="Signature">
{isLoading && ( {isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md"> <div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" /> <Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />

View File

@ -4,6 +4,7 @@ import React from 'react';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { FieldRootContainer } from '@documenso/ui/components/field/field'; import { FieldRootContainer } from '@documenso/ui/components/field/field';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export type SignatureFieldProps = { export type SignatureFieldProps = {
field: FieldWithSignature; field: FieldWithSignature;
@ -11,6 +12,8 @@ export type SignatureFieldProps = {
children: React.ReactNode; children: React.ReactNode;
onSign?: () => Promise<void> | void; onSign?: () => Promise<void> | void;
onRemove?: () => Promise<void> | void; onRemove?: () => Promise<void> | void;
type?: 'Date' | 'Email' | 'Name' | 'Signature';
tooltipText?: string | null;
}; };
export const SigningFieldContainer = ({ export const SigningFieldContainer = ({
@ -19,6 +22,8 @@ export const SigningFieldContainer = ({
onSign, onSign,
onRemove, onRemove,
children, children,
type,
tooltipText,
}: SignatureFieldProps) => { }: SignatureFieldProps) => {
const onSignFieldClick = async () => { const onSignFieldClick = async () => {
if (field.inserted) { if (field.inserted) {
@ -46,7 +51,22 @@ export const SigningFieldContainer = ({
/> />
)} )}
{field.inserted && !loading && ( {type === 'Date' && field.inserted && !loading && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick}
>
Remove
</button>
</TooltipTrigger>
{tooltipText && <TooltipContent className="max-w-xs">{tooltipText}</TooltipContent>}
</Tooltip>
)}
{type !== 'Date' && field.inserted && !loading && (
<button <button
className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100" className="text-destructive bg-background/40 absolute inset-0 z-10 flex h-full w-full items-center justify-center rounded-md text-sm opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100"
onClick={onRemoveSignedFieldClick} onClick={onRemoveSignedFieldClick}

View File

@ -13,12 +13,14 @@ export default function SignInPage() {
<SignInForm className="mt-4" /> <SignInForm className="mt-4" />
<p className="text-muted-foreground mt-6 text-center text-sm"> {process.env.NEXT_PUBLIC_DISABLE_SIGNUP !== 'true' && (
Don't have an account?{' '} <p className="text-muted-foreground mt-6 text-center text-sm">
<Link href="/signup" className="text-primary duration-200 hover:opacity-70"> Don't have an account?{' '}
Sign up <Link href="/signup" className="text-primary duration-200 hover:opacity-70">
</Link> Sign up
</p> </Link>
</p>
)}
<p className="mt-2.5 text-center"> <p className="mt-2.5 text-center">
<Link <Link

View File

@ -1,8 +1,13 @@
import Link from 'next/link'; import Link from 'next/link';
import { redirect } from 'next/navigation';
import { SignUpForm } from '~/components/forms/signup'; import { SignUpForm } from '~/components/forms/signup';
export default function SignUpPage() { export default function SignUpPage() {
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
redirect('/signin');
}
return ( return (
<div> <div>
<h1 className="text-4xl font-semibold">Create a new account</h1> <h1 className="text-4xl font-semibold">Create a new account</h1>

View File

@ -11,6 +11,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { import {
DOCUMENTS_PAGE_SHORTCUT, DOCUMENTS_PAGE_SHORTCUT,
SETTINGS_PAGE_SHORTCUT, SETTINGS_PAGE_SHORTCUT,
TEMPLATES_PAGE_SHORTCUT,
} from '@documenso/lib/constants/keyboard-shortcuts'; } from '@documenso/lib/constants/keyboard-shortcuts';
import { trpc as trpcReact } from '@documenso/trpc/react'; import { trpc as trpcReact } from '@documenso/trpc/react';
import { import {
@ -22,6 +23,7 @@ import {
CommandList, CommandList,
CommandShortcut, CommandShortcut,
} from '@documenso/ui/primitives/command'; } from '@documenso/ui/primitives/command';
import { THEMES_TYPE } from '@documenso/ui/primitives/constants';
const DOCUMENTS_PAGES = [ const DOCUMENTS_PAGES = [
{ {
@ -38,6 +40,14 @@ const DOCUMENTS_PAGES = [
{ label: 'Inbox documents', path: '/documents?status=INBOX' }, { label: 'Inbox documents', path: '/documents?status=INBOX' },
]; ];
const TEMPLATES_PAGES = [
{
label: 'All templates',
path: '/templates',
shortcut: TEMPLATES_PAGE_SHORTCUT.replace('+', ''),
},
];
const SETTINGS_PAGES = [ const SETTINGS_PAGES = [
{ {
label: 'Settings', label: 'Settings',
@ -123,10 +133,12 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]); const goToSettings = useCallback(() => push(SETTINGS_PAGES[0].path), [push]);
const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]); const goToDocuments = useCallback(() => push(DOCUMENTS_PAGES[0].path), [push]);
const goToTemplates = useCallback(() => push(TEMPLATES_PAGES[0].path), [push]);
useHotkeys(['ctrl+k', 'meta+k'], toggleOpen); useHotkeys(['ctrl+k', 'meta+k'], toggleOpen, { preventDefault: true });
useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings); useHotkeys(SETTINGS_PAGE_SHORTCUT, goToSettings);
useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments); useHotkeys(DOCUMENTS_PAGE_SHORTCUT, goToDocuments);
useHotkeys(TEMPLATES_PAGE_SHORTCUT, goToTemplates);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
// Escape goes to previous page // Escape goes to previous page
@ -173,6 +185,9 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
<CommandGroup heading="Documents"> <CommandGroup heading="Documents">
<Commands push={push} pages={DOCUMENTS_PAGES} /> <Commands push={push} pages={DOCUMENTS_PAGES} />
</CommandGroup> </CommandGroup>
<CommandGroup heading="Templates">
<Commands push={push} pages={TEMPLATES_PAGES} />
</CommandGroup>
<CommandGroup heading="Settings"> <CommandGroup heading="Settings">
<Commands push={push} pages={SETTINGS_PAGES} /> <Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup> </CommandGroup>
@ -214,9 +229,9 @@ const Commands = ({
const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => { const ThemeCommands = ({ setTheme }: { setTheme: (_theme: string) => void }) => {
const THEMES = useMemo( const THEMES = useMemo(
() => [ () => [
{ label: 'Light Mode', theme: 'light', icon: Sun }, { label: 'Light Mode', theme: THEMES_TYPE.LIGHT, icon: Sun },
{ label: 'Dark Mode', theme: 'dark', icon: Moon }, { label: 'Dark Mode', theme: THEMES_TYPE.DARK, icon: Moon },
{ label: 'System Theme', theme: 'system', icon: Monitor }, { label: 'System Theme', theme: THEMES_TYPE.SYSTEM, icon: Monitor },
], ],
[], [],
); );

View File

@ -3,6 +3,9 @@
import type { HTMLAttributes } from 'react'; import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -10,10 +13,22 @@ import { Button } from '@documenso/ui/primitives/button';
import { CommandMenu } from '../common/command-menu'; import { CommandMenu } from '../common/command-menu';
const navigationLinks = [
{
href: '/documents',
label: 'Documents',
},
{
href: '/templates',
label: 'Templates',
},
];
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(); const pathname = usePathname();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [modifierKey, setModifierKey] = useState(() => 'Ctrl'); const [modifierKey, setModifierKey] = useState(() => 'Ctrl');
@ -26,9 +41,29 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
return ( return (
<div <div
className={cn('ml-8 hidden flex-1 gap-x-6 md:flex md:justify-center', className)} className={cn(
'ml-8 hidden flex-1 items-center gap-x-12 md:flex md:justify-between',
className,
)}
{...props} {...props}
> >
<div className="flex items-baseline gap-x-6">
{navigationLinks.map(({ href, label }) => (
<Link
key={href}
href={href}
className={cn(
'text-muted-foreground dark:text-muted 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 dark:text-muted-foreground': pathname?.startsWith(href),
},
)}
>
{label}
</Link>
))}
</div>
<CommandMenu open={open} onOpenChange={setOpen} /> <CommandMenu open={open} onOpenChange={setOpen} />
<Button <Button
@ -47,19 +82,6 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</div> </div>
</div> </div>
</Button> </Button>
{/* We have no other subpaths rn */}
{/* <Link
href="/documents"
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-foreground': pathname?.startsWith('/documents'),
},
)}
>
Documents
</Link> */}
</div> </div>
); );
}; };

View File

@ -33,7 +33,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-[1000] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200', 'supports-backdrop-blur:bg-background/60 bg-background/95 sticky top-0 z-[50] flex h-16 w-full items-center border-b border-b-transparent backdrop-blur duration-200',
scrollY > 5 && 'border-b-border', scrollY > 5 && 'border-b-border',
className, className,
)} )}
@ -49,7 +49,7 @@ export const Header = ({ className, user, ...props }: HeaderProps) => {
<DesktopNav /> <DesktopNav />
<div className="flex gap-x-4"> <div className="flex gap-x-4 md:ml-8">
<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">

View File

@ -4,6 +4,7 @@ import Link from 'next/link';
import { import {
CreditCard, CreditCard,
FileSpreadsheet,
Lock, Lock,
LogOut, LogOut,
User as LucideUser, User as LucideUser,
@ -106,6 +107,13 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/templates" className="cursor-pointer">
<FileSpreadsheet className="mr-2 h-4 w-4" />
Templates
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuSub> <DropdownMenuSub>

View File

@ -1,9 +1,9 @@
import { HTMLAttributes } from 'react'; import type { 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 { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { SignatureIcon } from '@documenso/ui/icons/signature'; import { SignatureIcon } from '@documenso/ui/icons/signature';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';

View File

@ -1,8 +1,10 @@
'use client'; 'use client';
import { HTMLAttributes, useEffect, useState } from 'react'; import type { HTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
import { DateTime, DateTimeFormatOptions } from 'luxon'; import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { useLocale } from '@documenso/lib/client-only/providers/locale'; import { useLocale } from '@documenso/lib/client-only/providers/locale';

View File

@ -0,0 +1,50 @@
import { HTMLAttributes } from 'react';
import { Globe, Lock } from 'lucide-react';
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
import { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils';
type TemplateTypeIcon = {
label: string;
icon?: LucideIcon;
color: string;
};
type TemplateTypes = (typeof TemplateTypePrisma)[keyof typeof TemplateTypePrisma];
const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
PRIVATE: {
label: 'Private',
icon: Lock,
color: 'text-blue-600 dark:text-blue-300',
},
PUBLIC: {
label: 'Public',
icon: Globe,
color: 'text-green-500 dark:text-green-300',
},
};
export type TemplateTypeProps = HTMLAttributes<HTMLSpanElement> & {
type: TemplateTypes;
inheritColor?: boolean;
};
export const TemplateType = ({ className, type, inheritColor, ...props }: TemplateTypeProps) => {
const { label, icon: Icon, color } = TEMPLATE_TYPES[type];
return (
<span className={cn('flex items-center', className)} {...props}>
{Icon && (
<Icon
className={cn('mr-2 inline-block h-4 w-4', {
[color]: !inheritColor,
})}
/>
)}
{label}
</span>
);
};

View File

@ -23,6 +23,7 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZDisableTwoFactorAuthenticationForm = z.object({ export const ZDisableTwoFactorAuthenticationForm = z.object({
@ -107,38 +108,42 @@ export const DisableAuthenticatorAppDialog = ({
)} )}
className="flex flex-col gap-y-4" className="flex flex-col gap-y-4"
> >
<FormField <fieldset
name="password" className="flex flex-col gap-y-4"
control={disableTwoFactorAuthenticationForm.control} disabled={isDisableTwoFactorAuthenticationSubmitting}
render={({ field }) => ( >
<FormItem> <FormField
<FormLabel className="text-muted-foreground">Password</FormLabel> name="password"
<FormControl> control={disableTwoFactorAuthenticationForm.control}
<Input render={({ field }) => (
{...field} <FormItem>
type="password" <FormLabel className="text-muted-foreground">Password</FormLabel>
autoComplete="current-password" <FormControl>
value={field.value ?? ''} <PasswordInput
/> {...field}
</FormControl> autoComplete="current-password"
<FormMessage /> value={field.value ?? ''}
</FormItem> />
)} </FormControl>
/> <FormMessage />
</FormItem>
)}
/>
<FormField <FormField
name="backupCode" name="backupCode"
control={disableTwoFactorAuthenticationForm.control} control={disableTwoFactorAuthenticationForm.control}
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Backup Code</FormLabel> <FormLabel className="text-muted-foreground">Backup Code</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="text" value={field.value ?? ''} /> <Input {...field} type="text" value={field.value ?? ''} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</fieldset>
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}> <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>

View File

@ -27,6 +27,7 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list'; import { RecoveryCodeList } from './recovery-code-list';
@ -178,9 +179,8 @@ export const EnableAuthenticatorAppDialog = ({
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel> <FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl> <FormControl>
<Input <PasswordInput
{...field} {...field}
type="password"
autoComplete="current-password" autoComplete="current-password"
value={field.value ?? ''} value={field.value ?? ''}
/> />

View File

@ -22,7 +22,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { RecoveryCodeList } from './recovery-code-list'; import { RecoveryCodeList } from './recovery-code-list';
@ -108,9 +108,8 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode
<FormItem> <FormItem>
<FormLabel className="text-muted-foreground">Password</FormLabel> <FormLabel className="text-muted-foreground">Password</FormLabel>
<FormControl> <FormControl>
<Input <PasswordInput
{...field} {...field}
type="password"
autoComplete="current-password" autoComplete="current-password"
value={field.value ?? ''} value={field.value ?? ''}
/> />

View File

@ -9,9 +9,15 @@ import { z } from 'zod';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZForgotPasswordFormSchema = z.object({ export const ZForgotPasswordFormSchema = z.object({
@ -28,18 +34,15 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const { const form = useForm<TForgotPasswordFormSchema>({
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<TForgotPasswordFormSchema>({
values: { values: {
email: '', email: '',
}, },
resolver: zodResolver(ZForgotPasswordFormSchema), resolver: zodResolver(ZForgotPasswordFormSchema),
}); });
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation(); const { mutateAsync: forgotPassword } = trpc.profile.forgotPassword.useMutation();
const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => { const onFormSubmit = async ({ email }: TForgotPasswordFormSchema) => {
@ -52,29 +55,37 @@ export const ForgotPasswordForm = ({ className }: ForgotPasswordFormProps) => {
duration: 5000, duration: 5000,
}); });
reset(); form.reset();
router.push('/check-email'); router.push('/check-email');
}; };
return ( return (
<form <Form {...form}>
className={cn('flex w-full flex-col gap-y-4', className)} <form
onSubmit={handleSubmit(onFormSubmit)} className={cn('flex w-full flex-col gap-y-4', className)}
> onSubmit={form.handleSubmit(onFormSubmit)}
<div> >
<Label htmlFor="email" className="text-muted-foreground"> <fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
Email <FormField
</Label> control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} /> <Button size="lg" loading={isSubmitting}>
{isSubmitting ? 'Sending Reset Email...' : 'Reset Password'}
<FormErrorMessage className="mt-1.5" error={errors.email} /> </Button>
</div> </form>
</Form>
<Button size="lg" loading={isSubmitting}>
{isSubmitting ? 'Sending Reset Email...' : 'Reset Password'}
</Button>
</form>
); );
}; };

View File

@ -1,23 +1,25 @@
'use client'; 'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff, Loader } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input'; import {
import { Label } from '@documenso/ui/primitives/label'; Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '../form/form-error-message';
export const ZPasswordFormSchema = z export const ZPasswordFormSchema = z
.object({ .object({
currentPassword: z currentPassword: z
@ -48,16 +50,7 @@ export type PasswordFormProps = {
export const PasswordForm = ({ className }: PasswordFormProps) => { export const PasswordForm = ({ className }: PasswordFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false); const form = useForm<TPasswordFormSchema>({
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<TPasswordFormSchema>({
values: { values: {
currentPassword: '', currentPassword: '',
password: '', password: '',
@ -66,6 +59,8 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
resolver: zodResolver(ZPasswordFormSchema), resolver: zodResolver(ZPasswordFormSchema),
}); });
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation(); const { mutateAsync: updatePassword } = trpc.profile.updatePassword.useMutation();
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => { const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
@ -75,7 +70,7 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
password, password,
}); });
reset(); form.reset();
toast({ toast({
title: 'Password updated', title: 'Password updated',
@ -101,117 +96,61 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
}; };
return ( return (
<form <Form {...form}>
className={cn('flex w-full flex-col gap-y-4', className)} <form
onSubmit={handleSubmit(onFormSubmit)} className={cn('flex w-full flex-col gap-y-4', className)}
> onSubmit={form.handleSubmit(onFormSubmit)}
<div> >
<Label htmlFor="current-password" className="text-muted-foreground"> <fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
Current Password <FormField
</Label> control={form.control}
name="currentPassword"
<div className="relative"> render={({ field }) => (
<Input <FormItem>
id="current-password" <FormLabel>Current Password</FormLabel>
type={showCurrentPassword ? 'text' : 'password'} <FormControl>
minLength={6} <PasswordInput autoComplete="current-password" {...field} />
maxLength={72} </FormControl>
autoComplete="current-password" <FormMessage />
className="bg-background mt-2 pr-10" </FormItem>
{...register('currentPassword')} )}
/> />
<Button <FormField
variant="link" control={form.control}
type="button" name="password"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3" render={({ field }) => (
aria-label={showCurrentPassword ? 'Mask password' : 'Reveal password'} <FormItem>
onClick={() => setShowCurrentPassword((show) => !show)} <FormLabel>Password</FormLabel>
> <FormControl>
{showCurrentPassword ? ( <PasswordInput autoComplete="new-password" {...field} />
<EyeOff className="text-muted-foreground h-5 w-5" /> </FormControl>
) : ( <FormMessage />
<Eye className="text-muted-foreground h-5 w-5" /> </FormItem>
)} )}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.currentPassword} />
</div>
<div>
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('password')}
/> />
<Button <FormField
variant="link" control={form.control}
type="button" name="repeatedPassword"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3" render={({ field }) => (
aria-label={showPassword ? 'Mask password' : 'Reveal password'} <FormItem>
onClick={() => setShowPassword((show) => !show)} <FormLabel>Repeat Password</FormLabel>
> <FormControl>
{showPassword ? ( <PasswordInput autoComplete="new-password" {...field} />
<EyeOff className="text-muted-foreground h-5 w-5" /> </FormControl>
) : ( <FormMessage />
<Eye className="text-muted-foreground h-5 w-5" /> </FormItem>
)} )}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<div>
<Label htmlFor="repeated-password" className="text-muted-foreground">
Repeat Password
</Label>
<div className="relative">
<Input
id="repeated-password"
type={showConfirmPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('repeatedPassword')}
/> />
</fieldset>
<Button <div className="mt-4">
variant="link" <Button type="submit" loading={isSubmitting}>
type="button" {isSubmitting ? 'Updating password...' : 'Update password'}
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'}
onClick={() => setShowConfirmPassword((show) => !show)}
>
{showConfirmPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button> </Button>
</div> </div>
</form>
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} /> </Form>
</div>
<div className="mt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />}
Update password
</Button>
</div>
</form>
); );
}; };

View File

@ -3,8 +3,7 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Loader } from 'lucide-react'; import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import type { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
@ -12,13 +11,19 @@ import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { Label } from '@documenso/ui/primitives/label';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
import { FormErrorMessage } from '../form/form-error-message';
export const ZProfileFormSchema = z.object({ export const ZProfileFormSchema = z.object({
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), name: z.string().trim().min(1, { message: 'Please enter a valid name.' }),
signature: z.string().min(1, 'Signature Pad cannot be empty'), signature: z.string().min(1, 'Signature Pad cannot be empty'),
@ -36,12 +41,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { const form = useForm<TProfileFormSchema>({
register,
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TProfileFormSchema>({
values: { values: {
name: user.name ?? '', name: user.name ?? '',
signature: user.signature || '', signature: user.signature || '',
@ -49,6 +49,8 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
resolver: zodResolver(ZProfileFormSchema), resolver: zodResolver(ZProfileFormSchema),
}); });
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => { const onFormSubmit = async ({ name, signature }: TProfileFormSchema) => {
@ -84,56 +86,57 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
}; };
return ( return (
<form <Form {...form}>
className={cn('flex w-full flex-col gap-y-4', className)} <form
onSubmit={handleSubmit(onFormSubmit)} className={cn('flex w-full flex-col gap-y-4', className)}
> onSubmit={form.handleSubmit(onFormSubmit)}
<div> >
<Label htmlFor="full-name" className="text-muted-foreground"> <fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
Full Name <FormField
</Label> control={form.control}
name="name"
<Input id="full-name" type="text" className="bg-background mt-2" {...register('name')} /> render={({ field }) => (
<FormItem>
<FormErrorMessage className="mt-1.5" error={errors.name} /> <FormLabel>Full Name</FormLabel>
</div> <FormControl>
<Input type="text" {...field} />
<div> </FormControl>
<Label htmlFor="email" className="text-muted-foreground"> <FormMessage />
Email </FormItem>
</Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div>
<div>
<Label htmlFor="signature" className="text-muted-foreground">
Signature
</Label>
<div className="mt-2">
<Controller
control={control}
name="signature"
render={({ field: { onChange } }) => (
<SignaturePad
className="h-44 w-full"
containerClassName="rounded-lg border bg-background"
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')}
/>
)} )}
/> />
<FormErrorMessage className="mt-1.5" error={errors.signature} />
</div>
</div>
<div className="mt-4"> <div>
<Button type="submit" disabled={isSubmitting}> <Label htmlFor="email" className="text-muted-foreground">
{isSubmitting && <Loader className="mr-2 h-5 w-5 animate-spin" />} Email
Update profile </Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
</div>
<FormField
control={form.control}
name="signature"
render={({ field: { onChange } }) => (
<FormItem>
<FormLabel>Signature</FormLabel>
<FormControl>
<SignaturePad
className="h-44 w-full"
containerClassName="rounded-lg border bg-background"
defaultValue={user.signature ?? undefined}
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? 'Updating profile...' : 'Update profile'}
</Button> </Button>
</div> </form>
</form> </Form>
); );
}; };

View File

@ -1,11 +1,8 @@
'use client'; 'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff } from 'lucide-react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
@ -13,9 +10,15 @@ import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import {
import { Input } from '@documenso/ui/primitives/input'; Form,
import { Label } from '@documenso/ui/primitives/label'; FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZResetPasswordFormSchema = z export const ZResetPasswordFormSchema = z
@ -40,15 +43,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
const { toast } = useToast(); const { toast } = useToast();
const [showPassword, setShowPassword] = useState(false); const form = useForm<TResetPasswordFormSchema>({
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
reset,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TResetPasswordFormSchema>({
values: { values: {
password: '', password: '',
repeatedPassword: '', repeatedPassword: '',
@ -56,6 +51,8 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
resolver: zodResolver(ZResetPasswordFormSchema), resolver: zodResolver(ZResetPasswordFormSchema),
}); });
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation(); const { mutateAsync: resetPassword } = trpc.profile.resetPassword.useMutation();
const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => { const onFormSubmit = async ({ password }: Omit<TResetPasswordFormSchema, 'repeatedPassword'>) => {
@ -65,7 +62,7 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
token, token,
}); });
reset(); form.reset();
toast({ toast({
title: 'Password updated', title: 'Password updated',
@ -93,81 +90,45 @@ export const ResetPasswordForm = ({ className, token }: ResetPasswordFormProps)
}; };
return ( return (
<form <Form {...form}>
className={cn('flex w-full flex-col gap-y-4', className)} <form
onSubmit={handleSubmit(onFormSubmit)} className={cn('flex w-full flex-col gap-y-4', className)}
> onSubmit={form.handleSubmit(onFormSubmit)}
<div> >
<Label htmlFor="password" className="text-muted-foreground"> <fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<span>Password</span> <FormField
</Label> control={form.control}
name="password"
<div className="relative"> render={({ field }) => (
<Input <FormItem>
id="password" <FormLabel>Password</FormLabel>
type={showPassword ? 'text' : 'password'} <FormControl>
minLength={6} <PasswordInput {...field} />
maxLength={72} </FormControl>
autoComplete="new-password" <FormMessage />
className="bg-background mt-2 pr-10" </FormItem>
{...register('password')} )}
/> />
<Button <FormField
variant="link" control={form.control}
type="button" name="repeatedPassword"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3" render={({ field }) => (
aria-label={showPassword ? 'Mask password' : 'Reveal password'} <FormItem>
onClick={() => setShowPassword((show) => !show)} <FormLabel>Repeat Password</FormLabel>
> <FormControl>
{showPassword ? ( <PasswordInput {...field} />
<EyeOff className="text-muted-foreground h-5 w-5" /> </FormControl>
) : ( <FormMessage />
<Eye className="text-muted-foreground h-5 w-5" /> </FormItem>
)} )}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<div>
<Label htmlFor="repeatedPassword" className="text-muted-foreground">
<span>Repeat Password</span>
</Label>
<div className="relative">
<Input
id="repeated-password"
type={showConfirmPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('repeatedPassword')}
/> />
</fieldset>
<Button <Button type="submit" size="lg" loading={isSubmitting}>
variant="link" {isSubmitting ? 'Resetting Password...' : 'Reset Password'}
type="button" </Button>
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3" </form>
aria-label={showConfirmPassword ? 'Mask password' : 'Reveal password'} </Form>
onClick={() => setShowConfirmPassword((show) => !show)}
>
{showConfirmPassword ? (
<EyeOff className="text-muted-foreground h-5 w-5" />
) : (
<Eye className="text-muted-foreground h-5 w-5" />
)}
</Button>
</div>
<FormErrorMessage className="mt-1.5" error={errors.repeatedPassword} />
</div>
<Button size="lg" loading={isSubmitting}>
{isSubmitting ? 'Resetting Password...' : 'Reset Password'}
</Button>
</form>
); );
}; };

View File

@ -12,9 +12,16 @@ import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import {
import { Input, PasswordInput } from '@documenso/ui/primitives/input'; Form,
import { Label } from '@documenso/ui/primitives/label'; FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = { const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
@ -52,12 +59,7 @@ export const SignInForm = ({ className }: SignInFormProps) => {
'totp' | 'backup' 'totp' | 'backup'
>('totp'); >('totp');
const { const form = useForm<TSignInFormSchema>({
register,
handleSubmit,
setValue,
formState: { errors, isSubmitting },
} = useForm<TSignInFormSchema>({
values: { values: {
email: '', email: '',
password: '', password: '',
@ -67,9 +69,11 @@ export const SignInForm = ({ className }: SignInFormProps) => {
resolver: zodResolver(ZSignInFormSchema), resolver: zodResolver(ZSignInFormSchema),
}); });
const isSubmitting = form.formState.isSubmitting;
const onCloseTwoFactorAuthenticationDialog = () => { const onCloseTwoFactorAuthenticationDialog = () => {
setValue('totpCode', ''); form.setValue('totpCode', '');
setValue('backupCode', ''); form.setValue('backupCode', '');
setIsTwoFactorAuthenticationDialogOpen(false); setIsTwoFactorAuthenticationDialogOpen(false);
}; };
@ -78,11 +82,11 @@ export const SignInForm = ({ className }: SignInFormProps) => {
const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp'; const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp';
if (method === 'totp') { if (method === 'totp') {
setValue('backupCode', ''); form.setValue('backupCode', '');
} }
if (method === 'backup') { if (method === 'backup') {
setValue('totpCode', ''); form.setValue('totpCode', '');
} }
setTwoFactorAuthenticationMethod(method); setTwoFactorAuthenticationMethod(method);
@ -113,7 +117,6 @@ export const SignInForm = ({ className }: SignInFormProps) => {
if (result?.error && isErrorCode(result.error)) { if (result?.error && isErrorCode(result.error)) {
if (result.error === TwoFactorEnabledErrorCode) { if (result.error === TwoFactorEnabledErrorCode) {
setIsTwoFactorAuthenticationDialogOpen(true); setIsTwoFactorAuthenticationDialogOpen(true);
return; return;
} }
@ -156,64 +159,68 @@ export const SignInForm = ({ className }: SignInFormProps) => {
}; };
return ( return (
<form <Form {...form}>
className={cn('flex w-full flex-col gap-y-4', className)} <form
onSubmit={handleSubmit(onFormSubmit)} className={cn('flex w-full flex-col gap-y-4', className)}
> onSubmit={form.handleSubmit(onFormSubmit)}
<div>
<Label htmlFor="email" className="text-muted-forground">
Email
</Label>
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
<FormErrorMessage className="mt-1.5" error={errors.email} />
</div>
<div>
<Label htmlFor="password" className="text-muted-forground">
<span>Password</span>
</Label>
<PasswordInput
id="password"
minLength={6}
maxLength={72}
className="bg-background mt-2"
autoComplete="current-password"
{...register('password')}
/>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<Button
size="lg"
loading={isSubmitting}
disabled={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
> >
{isSubmitting ? 'Signing in...' : 'Sign In'} <fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
</Button> <FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase"> <FormField
<div className="bg-border h-px flex-1" /> control={form.control}
<span className="text-muted-foreground bg-transparent">Or continue with</span> name="password"
<div className="bg-border h-px flex-1" /> render={({ field }) => (
</div> <FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button <Button
type="button" type="submit"
size="lg" size="lg"
variant={'outline'} loading={isSubmitting}
className="bg-background text-muted-foreground border" className="dark:bg-documenso dark:hover:opacity-90"
disabled={isSubmitting} >
onClick={onSignInWithGoogleClick} {isSubmitting ? 'Signing in...' : 'Sign In'}
> </Button>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span>
<div className="bg-border h-px flex-1" />
</div>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
</form>
<Dialog <Dialog
open={isTwoFactorAuthenticationDialogOpen} open={isTwoFactorAuthenticationDialogOpen}
onOpenChange={onCloseTwoFactorAuthenticationDialog} onOpenChange={onCloseTwoFactorAuthenticationDialog}
@ -223,40 +230,40 @@ export const SignInForm = ({ className }: SignInFormProps) => {
<DialogTitle>Two-Factor Authentication</DialogTitle> <DialogTitle>Two-Factor Authentication</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit(onFormSubmit)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
{twoFactorAuthenticationMethod === 'totp' && ( <fieldset disabled={isSubmitting}>
<div> {twoFactorAuthenticationMethod === 'totp' && (
<Label htmlFor="totpCode" className="text-muted-forground"> <FormField
Authentication Token control={form.control}
</Label> name="totpCode"
render={({ field }) => (
<Input <FormItem>
id="totpCode" <FormLabel>Authentication Token</FormLabel>
type="text" <FormControl>
className="bg-background mt-2" <Input type="text" {...field} />
{...register('totpCode')} </FormControl>
<FormMessage />
</FormItem>
)}
/> />
)}
<FormErrorMessage className="mt-1.5" error={errors.totpCode} /> {twoFactorAuthenticationMethod === 'backup' && (
</div> <FormField
)} control={form.control}
name="backupCode"
{twoFactorAuthenticationMethod === 'backup' && ( render={({ field }) => (
<div> <FormItem>
<Label htmlFor="backupCode" className="text-muted-forground"> <FormLabel> Backup Code</FormLabel>
Backup Code <FormControl>
</Label> <Input type="text" {...field} />
</FormControl>
<Input <FormMessage />
id="backupCode" </FormItem>
type="text" )}
className="bg-background mt-2"
{...register('backupCode')}
/> />
)}
<FormErrorMessage className="mt-1.5" error={errors.backupCode} /> </fieldset>
</div>
)}
<div className="mt-4 flex items-center justify-between"> <div className="mt-4 flex items-center justify-between">
<Button <Button
@ -268,12 +275,12 @@ export const SignInForm = ({ className }: SignInFormProps) => {
</Button> </Button>
<Button type="submit" loading={isSubmitting}> <Button type="submit" loading={isSubmitting}>
Sign In {isSubmitting ? 'Signing in...' : 'Sign In'}
</Button> </Button>
</div> </div>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</form> </Form>
); );
}; };

View File

@ -1,11 +1,8 @@
'use client'; 'use client';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { Eye, EyeOff } from 'lucide-react';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
import { Controller, useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
@ -13,9 +10,16 @@ import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input'; import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label'; import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
@ -38,14 +42,8 @@ export type SignUpFormProps = {
export const SignUpForm = ({ className }: SignUpFormProps) => { export const SignUpForm = ({ className }: SignUpFormProps) => {
const { toast } = useToast(); const { toast } = useToast();
const analytics = useAnalytics(); const analytics = useAnalytics();
const [showPassword, setShowPassword] = useState(false);
const { const form = useForm<TSignUpFormSchema>({
control,
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<TSignUpFormSchema>({
values: { values: {
name: '', name: '',
email: '', email: '',
@ -55,6 +53,8 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
resolver: zodResolver(ZSignUpFormSchema), resolver: zodResolver(ZSignUpFormSchema),
}); });
const isSubmitting = form.formState.isSubmitting;
const { mutateAsync: signup } = trpc.auth.signup.useMutation(); const { mutateAsync: signup } = trpc.auth.signup.useMutation();
const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => { const onFormSubmit = async ({ name, email, password, signature }: TSignUpFormSchema) => {
@ -90,93 +90,83 @@ export const SignUpForm = ({ className }: SignUpFormProps) => {
}; };
return ( return (
<form <Form {...form}>
className={cn('flex w-full flex-col gap-y-4', className)} <form
onSubmit={handleSubmit(onFormSubmit)} className={cn('flex w-full flex-col gap-y-4', className)}
> onSubmit={form.handleSubmit(onFormSubmit)}
<div> >
<Label htmlFor="name" className="text-muted-foreground"> <fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
Name <FormField
</Label> control={form.control}
name="name"
<Input id="name" type="text" className="bg-background mt-2" {...register('name')} /> render={({ field }) => (
<FormItem>
{errors.name && <span className="mt-1 text-xs text-red-500">{errors.name.message}</span>} <FormLabel>Name</FormLabel>
</div> <FormControl>
<Input type="text" {...field} />
<div> </FormControl>
<Label htmlFor="email" className="text-muted-foreground"> <FormMessage />
Email </FormItem>
</Label> )}
<Input id="email" type="email" className="bg-background mt-2" {...register('email')} />
{errors.email && <span className="mt-1 text-xs text-red-500">{errors.email.message}</span>}
</div>
<div>
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
minLength={6}
maxLength={72}
autoComplete="new-password"
className="bg-background mt-2 pr-10"
{...register('password')}
/> />
<Button <FormField
variant="link" control={form.control}
type="button" name="email"
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3" render={({ field }) => (
aria-label={showPassword ? 'Mask password' : 'Reveal password'} <FormItem>
onClick={() => setShowPassword((show) => !show)} <FormLabel>Email</FormLabel>
> <FormControl>
{showPassword ? ( <Input type="email" {...field} />
<EyeOff className="text-muted-foreground h-5 w-5" /> </FormControl>
) : ( <FormMessage />
<Eye className="text-muted-foreground h-5 w-5" /> </FormItem>
)} )}
</Button> />
</div>
<FormErrorMessage className="mt-1.5" error={errors.password} />
</div>
<div> <FormField
<Label htmlFor="password" className="text-muted-foreground"> control={form.control}
Sign Here name="password"
</Label> render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div> <FormField
<Controller control={form.control}
control={control}
name="signature" name="signature"
render={({ field: { onChange } }) => ( render={({ field: { onChange } }) => (
<SignaturePad <FormItem>
className="h-36 w-full" <FormLabel>Sign Here</FormLabel>
containerClassName="mt-2 rounded-lg border bg-background" <FormControl>
onChange={(v) => onChange(v ?? '')} <SignaturePad
/> className="h-36 w-full"
containerClassName="mt-2 rounded-lg border bg-background"
onChange={(v) => onChange(v ?? '')}
/>
</FormControl>
<FormMessage />
</FormItem>
)} )}
/> />
</div> </fieldset>
<FormErrorMessage className="mt-1.5" error={errors.signature} /> <Button
</div> type="submit"
size="lg"
<Button loading={isSubmitting}
size="lg" className="dark:bg-documenso dark:hover:opacity-90"
loading={isSubmitting} >
disabled={isSubmitting} {isSubmitting ? 'Signing up...' : 'Sign Up'}
className="dark:bg-documenso dark:hover:opacity-90" </Button>
> </form>
{isSubmitting ? 'Signing up...' : 'Sign Up'} </Form>
</Button>
</form>
); );
}; };

View File

@ -0,0 +1,10 @@
export const truncateTitle = (title: string, maxLength: number = 16) => {
if (title.length <= maxLength) {
return title;
}
const start = title.slice(0, maxLength / 2);
const end = title.slice(-maxLength / 2);
return `${start}.....${end}`;
};

View File

@ -2,7 +2,7 @@
import React from 'react'; import React from 'react';
import { Session } from 'next-auth'; import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react'; import { SessionProvider } from 'next-auth/react';
export type NextAuthProviderProps = { export type NextAuthProviderProps = {

View File

@ -33,7 +33,6 @@ services:
- SMTP_MAIL_USER=username - SMTP_MAIL_USER=username
- SMTP_MAIL_PASSWORD=password - SMTP_MAIL_PASSWORD=password
- MAIL_FROM=admin@example.com - MAIL_FROM=admin@example.com
- NEXT_PUBLIC_ALLOW_SIGNUP=true
ports: ports:
- 3000:3000 - 3000:3000
volumes: volumes:

View File

@ -1,6 +1,7 @@
/** @type {import('lint-staged').Config} */
module.exports = { module.exports = {
'**/*.{ts,tsx,cts,mts}': ['eslint --fix'], '**/*.{ts,tsx,cts,mts}': (files) => `eslint --fix ${files.join(' ')}`,
'**/*.{js,jsx,cjs,mjs}': ['prettier --write'], '**/*.{js,jsx,cjs,mjs}': (files) => `prettier --write ${files.join(' ')}`,
'**/*.{yml,mdx}': ['prettier --write'], '**/*.{yml,mdx}': (files) => `prettier --write ${files.join(' ')}`,
'**/*/package.json': ['npm run precommit'], '**/*/package.json': 'npm run precommit',
}; };

985
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,6 @@
"turbo": "^1.9.3" "turbo": "^1.9.3"
}, },
"name": "@documenso/root", "name": "@documenso/root",
"packageManager": "npm@8.19.2",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"

View File

@ -1,10 +1,10 @@
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { stripe } from '@documenso/lib/server-only/stripe';
import { getFlag } from '@documenso/lib/universal/get-feature-flag'; import { getFlag } from '@documenso/lib/universal/get-feature-flag';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client'; import { SubscriptionStatus } from '@documenso/prisma/client';
import { getPricesByType } from '../stripe/get-prices-by-type';
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants'; import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
import { ERROR_CODES } from './errors'; import { ERROR_CODES } from './errors';
import { ZLimitsSchema } from './schema'; import { ZLimitsSchema } from './schema';
@ -43,23 +43,29 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
let quota = structuredClone(FREE_PLAN_LIMITS); let quota = structuredClone(FREE_PLAN_LIMITS);
let remaining = structuredClone(FREE_PLAN_LIMITS); let remaining = structuredClone(FREE_PLAN_LIMITS);
// Since we store details and allow for past due plans we need to check if the subscription is active. const activeSubscriptions = user.Subscription.filter(
if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) { ({ status }) => status === SubscriptionStatus.ACTIVE,
const { product } = await stripe.prices );
.retrieve(user.Subscription.priceId, {
expand: ['product'],
})
.catch((err) => {
console.error(err);
throw err;
});
if (typeof product === 'string') { if (activeSubscriptions.length > 0) {
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED); const individualPrices = await getPricesByType('individual');
for (const subscription of activeSubscriptions) {
const price = individualPrices.find((price) => price.id === subscription.priceId);
if (!price || typeof price.product === 'string' || price.product.deleted) {
continue;
}
const currentQuota = ZLimitsSchema.parse(
'metadata' in price.product ? price.product.metadata : {},
);
// Use the subscription with the highest quota.
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
quota = currentQuota;
remaining = structuredClone(quota);
}
} }
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
remaining = structuredClone(quota);
} }
const documents = await prisma.document.count({ const documents = await prisma.document.count({

View File

@ -1,31 +0,0 @@
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

@ -1,4 +1,8 @@
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
export const getStripeCustomerByEmail = async (email: string) => { export const getStripeCustomerByEmail = async (email: string) => {
const foundStripeCustomers = await stripe.customers.list({ const foundStripeCustomers = await stripe.customers.list({
@ -17,3 +21,74 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
return null; return null;
} }
}; };
/**
* Get a stripe customer by user.
*
* Will create a Stripe customer and update the relevant user if one does not exist.
*/
export const getStripeCustomerByUser = async (user: User) => {
if (user.customerId) {
const stripeCustomer = await getStripeCustomerById(user.customerId);
if (!stripeCustomer) {
throw new Error('Missing Stripe customer');
}
return {
user,
stripeCustomer,
};
}
let stripeCustomer = await getStripeCustomerByEmail(user.email);
const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted);
if (!stripeCustomer) {
stripeCustomer = await stripe.customers.create({
name: user.name ?? undefined,
email: user.email,
metadata: {
userId: user.id,
},
});
}
const updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data: {
customerId: stripeCustomer.id,
},
});
// Sync subscriptions if the customer already exists for back filling the DB
// and local development.
if (isSyncRequired) {
await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => {
console.error(e);
});
}
return {
user: updatedUser,
stripeCustomer,
};
};
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
const stripeSubscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
});
await Promise.all(
stripeSubscriptions.data.map(async (subscription) =>
onSubscriptionUpdated({
userId,
subscription,
}),
),
);
};

View File

@ -1,4 +1,4 @@
import Stripe from 'stripe'; import type Stripe from 'stripe';
import { stripe } from '@documenso/lib/server-only/stripe'; import { stripe } from '@documenso/lib/server-only/stripe';
@ -7,7 +7,14 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>; export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
export const getPricesByInterval = async () => { export type GetPricesByIntervalOptions = {
/**
* Filter products by their meta 'type' attribute.
*/
type?: 'individual';
};
export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => {
let { data: prices } = await stripe.prices.search({ let { data: prices } = await stripe.prices.search({
query: `active:'true' type:'recurring'`, query: `active:'true' type:'recurring'`,
expand: ['data.product'], expand: ['data.product'],
@ -19,8 +26,10 @@ export const getPricesByInterval = async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const product = price.product as Stripe.Product; const product = price.product as Stripe.Product;
const filter = !type || product.metadata?.type === type;
// Filter out prices for products that are not active. // Filter out prices for products that are not active.
return product.active; return product.active && filter;
}); });
const intervals: PriceIntervals = { const intervals: PriceIntervals = {

View File

@ -0,0 +1,11 @@
import { stripe } from '@documenso/lib/server-only/stripe';
export const getPricesByType = async (type: 'individual') => {
const { data: prices } = await stripe.prices.search({
query: `metadata['type']:'${type}' type:'recurring'`,
expand: ['data.product'],
limit: 100,
});
return prices;
};

View File

@ -75,23 +75,23 @@ export const stripeWebhookHandler = async (
// Finally, attempt to get the user ID from the subscription within the database. // Finally, attempt to get the user ID from the subscription within the database.
if (!userId && customerId) { if (!userId && customerId) {
const result = await prisma.subscription.findFirst({ const result = await prisma.user.findFirst({
select: { select: {
userId: true, id: true,
}, },
where: { where: {
customerId, customerId,
}, },
}); });
if (!result?.userId) { if (!result?.id) {
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: 'User not found', message: 'User not found',
}); });
} }
userId = result.userId; userId = result.id;
} }
const subscriptionId = const subscriptionId =
@ -124,23 +124,23 @@ export const stripeWebhookHandler = async (
? subscription.customer ? subscription.customer
: subscription.customer.id; : subscription.customer.id;
const result = await prisma.subscription.findFirst({ const result = await prisma.user.findFirst({
select: { select: {
userId: true, id: true,
}, },
where: { where: {
customerId, customerId,
}, },
}); });
if (!result?.userId) { if (!result?.id) {
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: 'User not found', message: 'User not found',
}); });
} }
await onSubscriptionUpdated({ userId: result.userId, subscription }); await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
@ -182,23 +182,23 @@ export const stripeWebhookHandler = async (
}); });
} }
const result = await prisma.subscription.findFirst({ const result = await prisma.user.findFirst({
select: { select: {
userId: true, id: true,
}, },
where: { where: {
customerId, customerId,
}, },
}); });
if (!result?.userId) { if (!result?.id) {
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: 'User not found', message: 'User not found',
}); });
} }
await onSubscriptionUpdated({ userId: result.userId, subscription }); await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
@ -233,23 +233,23 @@ export const stripeWebhookHandler = async (
}); });
} }
const result = await prisma.subscription.findFirst({ const result = await prisma.user.findFirst({
select: { select: {
userId: true, id: true,
}, },
where: { where: {
customerId, customerId,
}, },
}); });
if (!result?.userId) { if (!result?.id) {
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: 'User not found', message: 'User not found',
}); });
} }
await onSubscriptionUpdated({ userId: result.userId, subscription }); await onSubscriptionUpdated({ userId: result.id, subscription });
return res.status(200).json({ return res.status(200).json({
success: true, success: true,

View File

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

View File

@ -1,6 +1,6 @@
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { Stripe } from '@documenso/lib/server-only/stripe'; import type { Stripe } from '@documenso/lib/server-only/stripe';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { SubscriptionStatus } from '@documenso/prisma/client'; import { SubscriptionStatus } from '@documenso/prisma/client';
@ -13,9 +13,6 @@ export const onSubscriptionUpdated = async ({
userId, userId,
subscription, subscription,
}: OnSubscriptionUpdatedOptions) => { }: OnSubscriptionUpdatedOptions) => {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
const status = match(subscription.status) const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE) .with('active', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE) .with('past_due', () => SubscriptionStatus.PAST_DUE)
@ -23,22 +20,22 @@ export const onSubscriptionUpdated = async ({
await prisma.subscription.upsert({ await prisma.subscription.upsert({
where: { where: {
customerId, planId: subscription.id,
}, },
create: { create: {
customerId,
status: status, status: status,
planId: subscription.id, planId: subscription.id,
priceId: subscription.items.data[0].price.id, priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000), periodEnd: new Date(subscription.current_period_end * 1000),
userId, userId,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
}, },
update: { update: {
customerId,
status: status, status: status,
planId: subscription.id, planId: subscription.id,
priceId: subscription.items.data[0].price.id, priceId: subscription.items.data[0].price.id,
periodEnd: new Date(subscription.current_period_end * 1000), periodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
}, },
}); });
}; };

View File

@ -13,7 +13,7 @@
"eslint-config-next": "13.4.19", "eslint-config-next": "13.4.19",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-config-turbo": "^1.9.3", "eslint-config-turbo": "^1.9.3",
"eslint-plugin-package-json": "^0.1.4", "eslint-plugin-package-json": "^0.2.0",
"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-plugin-unused-imports": "^3.0.0", "eslint-plugin-unused-imports": "^3.0.0",

View File

@ -1,4 +1,5 @@
import { ReadStatus, Recipient, SendStatus, SigningStatus } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => { export const getRecipientType = (recipient: Recipient) => {
if ( if (

View File

@ -0,0 +1,79 @@
import { DateTime } from 'luxon';
import { DEFAULT_DOCUMENT_TIME_ZONE } from './time-zones';
export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
export const DATE_FORMATS = [
{
key: 'yyyy-MM-dd_hh:mm_a',
label: 'YYYY-MM-DD HH:mm a',
value: DEFAULT_DOCUMENT_DATE_FORMAT,
},
{
key: 'YYYYMMDD',
label: 'YYYY-MM-DD',
value: 'YYYY-MM-DD',
},
{
key: 'DDMMYYYY',
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',
value: 'yyyy-MM-dd HH:mm',
},
{
key: 'YYMMDD',
label: 'YY-MM-DD',
value: 'yy-MM-dd hh:mm a',
},
{
key: 'YYYYMMDDhhmmss',
label: 'YYYY-MM-DD HH:mm:ss',
value: 'yyyy-MM-dd HH:mm:ss',
},
{
key: 'MonthDateYear',
label: 'Month Date, Year',
value: 'MMMM dd, yyyy hh:mm a',
},
{
key: 'DayMonthYear',
label: 'Day, Month Year',
value: 'EEEE, MMMM dd, yyyy hh:mm a',
},
{
key: 'ISO8601',
label: 'ISO 8601',
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
},
];
export const convertToLocalSystemFormat = (
customText: string,
dateFormat: string | null = DEFAULT_DOCUMENT_DATE_FORMAT,
timeZone: string | null = DEFAULT_DOCUMENT_TIME_ZONE,
): string => {
const coalescedDateFormat = dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT;
const coalescedTimeZone = timeZone ?? DEFAULT_DOCUMENT_TIME_ZONE;
const parsedDate = DateTime.fromFormat(customText, coalescedDateFormat, {
zone: coalescedTimeZone,
});
if (!parsedDate.isValid) {
return 'Invalid date';
}
const formattedDate = parsedDate.toLocal().toFormat(coalescedDateFormat);
return formattedDate;
};

View File

@ -1,2 +1,3 @@
export const SETTINGS_PAGE_SHORTCUT = 'N+S'; export const SETTINGS_PAGE_SHORTCUT = 'N+S';
export const DOCUMENTS_PAGE_SHORTCUT = 'N+D'; export const DOCUMENTS_PAGE_SHORTCUT = 'N+D';
export const TEMPLATES_PAGE_SHORTCUT = 'N+T';

View File

@ -0,0 +1,44 @@
import { rawTimeZones, timeZonesNames } from '@vvo/tzdb';
export const TIME_ZONE_DATA = rawTimeZones;
export const DEFAULT_DOCUMENT_TIME_ZONE = 'Etc/UTC';
export type TimeZone = {
name: string;
rawOffsetInMinutes: number;
};
export const minutesToHours = (minutes: number): string => {
const hours = Math.abs(Math.floor(minutes / 60));
const min = Math.abs(minutes % 60);
const sign = minutes >= 0 ? '+' : '-';
return `${sign}${String(hours).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
};
const getGMTOffsets = (timezones: TimeZone[]): string[] => {
const gmtOffsets: string[] = [];
for (const timezone of timezones) {
const offsetValue = minutesToHours(timezone.rawOffsetInMinutes);
const gmtText = `(${offsetValue})`;
gmtOffsets.push(`${timezone.name} ${gmtText}`);
}
return gmtOffsets;
};
export const splitTimeZone = (input: string | null): string => {
if (input === null) {
return '';
}
const [timeZone] = input.split('(');
return timeZone.trim();
};
export const TIME_ZONES_FULL = getGMTOffsets(TIME_ZONE_DATA);
export const TIME_ZONES = ['Etc/UTC', ...timeZonesNames];

View File

@ -162,5 +162,17 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
return session; return session;
}, },
async signIn({ user }) {
// We do this to stop OAuth providers from creating an account
// when signups are disabled
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
const userData = await getUserByEmail({ email: user.email! });
return !!userData;
}
return true;
},
}, },
}; };

View File

@ -31,6 +31,7 @@
"@scure/base": "^1.1.3", "@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@upstash/redis": "^1.20.6", "@upstash/redis": "^1.20.6",
"@vvo/tzdb": "^6.117.0",
"bcrypt": "^5.1.0", "bcrypt": "^5.1.0",
"luxon": "^3.4.0", "luxon": "^3.4.0",
"nanoid": "^4.0.2", "nanoid": "^4.0.2",

View File

@ -19,9 +19,11 @@ export const getRecipientsStats = async () => {
results.forEach((result) => { results.forEach((result) => {
const { readStatus, signingStatus, sendStatus, _count } = result; const { readStatus, signingStatus, sendStatus, _count } = result;
stats[readStatus] += _count; stats[readStatus] += _count;
stats[signingStatus] += _count; stats[signingStatus] += _count;
stats[sendStatus] += _count; stats[sendStatus] += _count;
stats.TOTAL_RECIPIENTS += _count; stats.TOTAL_RECIPIENTS += _count;
}); });

View File

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

View File

@ -6,13 +6,26 @@ export type CreateDocumentMetaOptions = {
documentId: number; documentId: number;
subject: string; subject: string;
message: string; message: string;
timezone: string;
dateFormat: string;
userId: number;
}; };
export const upsertDocumentMeta = async ({ export const upsertDocumentMeta = async ({
subject, subject,
message, message,
timezone,
dateFormat,
documentId, documentId,
userId,
}: CreateDocumentMetaOptions) => { }: CreateDocumentMetaOptions) => {
await prisma.document.findFirstOrThrow({
where: {
id: documentId,
userId,
},
});
return await prisma.documentMeta.upsert({ return await prisma.documentMeta.upsert({
where: { where: {
documentId, documentId,
@ -20,11 +33,15 @@ export const upsertDocumentMeta = async ({
create: { create: {
subject, subject,
message, message,
dateFormat,
timezone,
documentId, documentId,
}, },
update: { update: {
subject, subject,
message, message,
dateFormat,
timezone,
}, },
}); });
}; };

View File

@ -25,6 +25,8 @@ export const duplicateDocumentById = async ({ id, userId }: DuplicateDocumentByI
select: { select: {
message: true, message: true,
subject: true, subject: true,
dateFormat: true,
timezone: true,
}, },
}, },
}, },

View File

@ -8,7 +8,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import type { FindResultSet } from '../../types/find-result-set'; import type { FindResultSet } from '../../types/find-result-set';
export interface FindDocumentsOptions { export type FindDocumentsOptions = {
userId: number; userId: number;
term?: string; term?: string;
status?: ExtendedDocumentStatus; status?: ExtendedDocumentStatus;
@ -19,7 +19,7 @@ export interface FindDocumentsOptions {
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
}; };
period?: '' | '7d' | '14d' | '30d'; period?: '' | '7d' | '14d' | '30d';
} };
export const findDocuments = async ({ export const findDocuments = async ({
userId, userId,

View File

@ -0,0 +1,13 @@
import { prisma } from '@documenso/prisma';
export interface GetDocumentMetaByDocumentIdOptions {
id: number;
}
export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => {
return await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: id,
},
});
};

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