mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Merge branch 'main' into refactor-forms
This commit is contained in:
16
.env.example
16
.env.example
@ -29,15 +29,15 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_password"
|
|||||||
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
# OPTIONAL: Defines the storage transport to use. Available options: database (default) | s3
|
||||||
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
NEXT_PUBLIC_UPLOAD_TRANSPORT="database"
|
||||||
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
# OPTIONAL: Defines the endpoint to use for the S3 storage transport. Relevant when using third-party S3-compatible providers.
|
||||||
NEXT_PRIVATE_UPLOAD_ENDPOINT=
|
NEXT_PRIVATE_UPLOAD_ENDPOINT="http://127.0.0.1:9002"
|
||||||
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
# OPTIONAL: Defines the region to use for the S3 storage transport. Defaults to us-east-1.
|
||||||
NEXT_PRIVATE_UPLOAD_REGION=
|
NEXT_PRIVATE_UPLOAD_REGION="unknown"
|
||||||
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
# REQUIRED: Defines the bucket to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_BUCKET=
|
NEXT_PRIVATE_UPLOAD_BUCKET="documenso"
|
||||||
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
# OPTIONAL: Defines the access key ID to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID=
|
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID="documenso"
|
||||||
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
# OPTIONAL: Defines the secret access key to use for the S3 storage transport.
|
||||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY=
|
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY="password"
|
||||||
|
|
||||||
# [[SMTP]]
|
# [[SMTP]]
|
||||||
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
# OPTIONAL: Defines the transport to use for sending emails. Available options: smtp-auth (default) | smtp-api | mailchannels
|
||||||
@ -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]]
|
||||||
|
|||||||
4
.github/workflows/issue-assignee-check.yml
vendored
4
.github/workflows/issue-assignee-check.yml
vendored
@ -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:
|
||||||
|
|||||||
4
.github/workflows/pr-review-reminder.yml
vendored
4
.github/workflows/pr-review-reminder.yml
vendored
@ -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:
|
||||||
|
|||||||
24
.github/workflows/semantic-pull-requests.yml
vendored
24
.github/workflows/semantic-pull-requests.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: "Validate PR Name"
|
name: 'Validate PR Name'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
@ -9,13 +9,31 @@ on:
|
|||||||
- synchronize
|
- synchronize
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: read
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-pr:
|
validate-pr:
|
||||||
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
|
||||||
|
|||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -22,4 +22,4 @@ jobs:
|
|||||||
close-issue-message: 'This issue has been closed because of inactivity.'
|
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'
|
exempt-issue-labels: 'WIP,on-hold,needs review,roadmap,assigned'
|
||||||
|
|||||||
@ -139,11 +139,13 @@ npm run d
|
|||||||
|
|
||||||
1. **App** - http://localhost:3000
|
1. **App** - http://localhost:3000
|
||||||
2. **Incoming Mail Access** - http://localhost:9000
|
2. **Incoming Mail Access** - http://localhost:9000
|
||||||
|
|
||||||
3. **Database Connection Details**
|
3. **Database Connection Details**
|
||||||
|
|
||||||
- **Port**: 54320
|
- **Port**: 54320
|
||||||
- **Connection**: Use your favorite database client to connect using the provided port.
|
- **Connection**: Use your favorite database client to connect using the provided port.
|
||||||
|
|
||||||
|
4. **S3 Storage Dashboard** - http://localhost:9001
|
||||||
|
|
||||||
## Developer Setup
|
## Developer Setup
|
||||||
|
|
||||||
### Manual Setup
|
### Manual Setup
|
||||||
|
|||||||
2
apps/marketing/process-env.d.ts
vendored
2
apps/marketing/process-env.d.ts
vendored
@ -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;
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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}>
|
||||||
|
|||||||
@ -26,6 +26,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
|
||||||
@ -48,13 +49,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);
|
||||||
|
|
||||||
@ -81,11 +85,11 @@ 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 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,16 +97,16 @@ export const Widget = ({ className, children, ...props }: WidgetProps) => {
|
|||||||
}, [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();
|
||||||
@ -226,7 +230,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>
|
||||||
@ -238,7 +242,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"
|
||||||
|
|||||||
5
apps/marketing/src/components/constants.ts
Normal file
5
apps/marketing/src/components/constants.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const STEP = {
|
||||||
|
EMAIL: 'EMAIL',
|
||||||
|
NAME: 'NAME',
|
||||||
|
SIGN: "SIGN"
|
||||||
|
} as const;
|
||||||
2
apps/web/process-env.d.ts
vendored
2
apps/web/process-env.d.ts
vendored
@ -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;
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -79,8 +79,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) {
|
|||||||
return searchDocumentsData.map((document) => ({
|
return searchDocumentsData.map((document) => ({
|
||||||
label: document.title,
|
label: document.title,
|
||||||
path: `/documents/${document.id}`,
|
path: `/documents/${document.id}`,
|
||||||
value:
|
value: [document.id, document.title, ...document.Recipient.map((r) => r.email)].join(' '),
|
||||||
document.title + ' ' + document.Recipient.map((recipient) => recipient.email).join(' '),
|
|
||||||
}));
|
}));
|
||||||
}, [searchDocumentsData]);
|
}, [searchDocumentsData]);
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { HTMLAttributes, useEffect, useState } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { User } from '@documenso/prisma/client';
|
import type { User } from '@documenso/prisma/client';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
|
|
||||||
import { Logo } from '~/components/branding/logo';
|
import { Logo } from '~/components/branding/logo';
|
||||||
@ -32,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-50 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-[1000] 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,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -17,3 +17,20 @@ services:
|
|||||||
- 9000:9000
|
- 9000:9000
|
||||||
- 2500:2500
|
- 2500:2500
|
||||||
- 1100:1100
|
- 1100:1100
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
container_name: minio
|
||||||
|
ports:
|
||||||
|
- 9002:9002
|
||||||
|
- 9001:9001
|
||||||
|
volumes:
|
||||||
|
- minio:/data
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: documenso
|
||||||
|
MINIO_ROOT_PASSWORD: password
|
||||||
|
entrypoint: sh
|
||||||
|
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
'**/*.{js,jsx,cjs,mjs,ts,tsx,cts,mts,mdx}': ['prettier --write'],
|
'**/*.{ts,tsx,cts,mts}': ['eslint --fix'],
|
||||||
'**/*.yml': ['prettier --write'],
|
'**/*.{js,jsx,cjs,mjs}': ['prettier --write'],
|
||||||
|
'**/*.{yml,mdx}': ['prettier --write'],
|
||||||
|
'**/*/package.json': ['npm run precommit'],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,7 +22,8 @@
|
|||||||
"prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma",
|
"prisma:seed": "npm run with:env -- npm run prisma:seed -w @documenso/prisma",
|
||||||
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
"prisma:studio": "npm run with:env -- npx prisma studio --schema packages/prisma/schema.prisma",
|
||||||
"with:env": "dotenv -e .env -e .env.local --",
|
"with:env": "dotenv -e .env -e .env.local --",
|
||||||
"reset:hard": "npm run clean && npm i && npm run prisma:generate"
|
"reset:hard": "npm run clean && npm i && npm run prisma:generate",
|
||||||
|
"precommit": "npm install && git add package.json package-lock.json"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"npm": ">=8.6.0",
|
"npm": ">=8.6.0",
|
||||||
@ -42,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/*"
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
11
packages/ee/server-only/stripe/get-prices-by-type.ts
Normal file
11
packages/ee/server-only/stripe/get-prices-by-type.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
export type GetSubscriptionByUserIdOptions = {
|
|
||||||
userId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
|
|
||||||
return await prisma.subscription.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type GetSubscriptionsByUserIdOptions = {
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => {
|
||||||
|
return await prisma.subscription.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import { hash } from 'bcrypt';
|
import { hash } from 'bcrypt';
|
||||||
|
|
||||||
|
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { IdentityProvider } from '@documenso/prisma/client';
|
import { IdentityProvider } from '@documenso/prisma/client';
|
||||||
|
|
||||||
import { SALT_ROUNDS } from '../../constants/auth';
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
import { getFlag } from '../../universal/get-feature-flag';
|
||||||
|
|
||||||
export interface CreateUserOptions {
|
export interface CreateUserOptions {
|
||||||
name: string;
|
name: string;
|
||||||
@ -13,6 +15,8 @@ export interface CreateUserOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
|
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
|
||||||
|
const isBillingEnabled = await getFlag('app_billing');
|
||||||
|
|
||||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||||
|
|
||||||
const userExists = await prisma.user.findFirst({
|
const userExists = await prisma.user.findFirst({
|
||||||
@ -25,7 +29,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
|||||||
throw new Error('User already exists');
|
throw new Error('User already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await prisma.user.create({
|
let user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
email: email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
@ -34,4 +38,15 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
|||||||
identityProvider: IdentityProvider.DOCUMENSO,
|
identityProvider: IdentityProvider.DOCUMENSO,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isBillingEnabled) {
|
||||||
|
try {
|
||||||
|
const stripeSession = await getStripeCustomerByUser(user);
|
||||||
|
user = stripeSession.user;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[planId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[customerId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Made the column `planId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
- Made the column `priceId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- Custom migration statement
|
||||||
|
DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS NULL;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Subscription_customerId_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Subscription_userId_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Subscription" ALTER COLUMN "planId" SET NOT NULL,
|
||||||
|
ALTER COLUMN "priceId" SET NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "customerId" TEXT;
|
||||||
|
ALTER TABLE "Subscription" DROP COLUMN "customerId";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Subscription_planId_key" ON "Subscription"("planId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_customerId_key" ON "User"("customerId");
|
||||||
@ -21,6 +21,7 @@ enum Role {
|
|||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String?
|
name String?
|
||||||
|
customerId String? @unique
|
||||||
email String @unique
|
email String @unique
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
password String?
|
password String?
|
||||||
@ -34,7 +35,7 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
Document Document[]
|
Document Document[]
|
||||||
Subscription Subscription?
|
Subscription Subscription[]
|
||||||
PasswordResetToken PasswordResetToken[]
|
PasswordResetToken PasswordResetToken[]
|
||||||
twoFactorSecret String?
|
twoFactorSecret String?
|
||||||
twoFactorEnabled Boolean @default(false)
|
twoFactorEnabled Boolean @default(false)
|
||||||
@ -72,18 +73,16 @@ enum SubscriptionStatus {
|
|||||||
model Subscription {
|
model Subscription {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
status SubscriptionStatus @default(INACTIVE)
|
status SubscriptionStatus @default(INACTIVE)
|
||||||
planId String?
|
planId String @unique
|
||||||
priceId String?
|
priceId String
|
||||||
customerId String
|
|
||||||
periodEnd DateTime?
|
periodEnd DateTime?
|
||||||
userId Int @unique
|
userId Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
cancelAtPeriodEnd Boolean @default(false)
|
cancelAtPeriodEnd Boolean @default(false)
|
||||||
|
|
||||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([customerId])
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,13 @@ import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
|
|||||||
export const authRouter = router({
|
export const authRouter = router({
|
||||||
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
|
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
|
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Signups are disabled.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { name, email, password, signature } = input;
|
const { name, email, password, signature } = input;
|
||||||
|
|
||||||
const user = await createUser({ name, email, password, signature });
|
const user = await createUser({ name, email, password, signature });
|
||||||
|
|||||||
4
packages/tsconfig/process-env.d.ts
vendored
4
packages/tsconfig/process-env.d.ts
vendored
@ -10,8 +10,6 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_ENCRYPTION_KEY: string;
|
NEXT_PRIVATE_ENCRYPTION_KEY: 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;
|
||||||
@ -55,6 +53,8 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
||||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
||||||
|
|
||||||
|
NEXT_PUBLIC_DISABLE_SIGNUP?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vercel environment variables
|
* Vercel environment variables
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<
|
|||||||
<ToastPrimitives.Viewport
|
<ToastPrimitives.Viewport
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
'fixed top-0 z-[9999] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -67,14 +67,10 @@ services:
|
|||||||
sync: false
|
sync: false
|
||||||
- key: NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
- key: NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID
|
||||||
sync: false
|
sync: false
|
||||||
- key: NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID
|
|
||||||
sync: false
|
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
- key: NEXT_PUBLIC_POSTHOG_KEY
|
- key: NEXT_PUBLIC_POSTHOG_KEY
|
||||||
sync: false
|
sync: false
|
||||||
- key: NEXT_PUBLIC_POSTHOG_HOST
|
|
||||||
value: 'https://eu.posthog.com'
|
|
||||||
- key: NEXT_PUBLIC_FEATURE_BILLING_ENABLED
|
- key: NEXT_PUBLIC_FEATURE_BILLING_ENABLED
|
||||||
sync: false
|
sync: false
|
||||||
|
|
||||||
|
|||||||
@ -40,11 +40,10 @@
|
|||||||
"NEXT_PUBLIC_WEBAPP_URL",
|
"NEXT_PUBLIC_WEBAPP_URL",
|
||||||
"NEXT_PUBLIC_MARKETING_URL",
|
"NEXT_PUBLIC_MARKETING_URL",
|
||||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
|
||||||
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
|
||||||
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID",
|
|
||||||
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
"NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID",
|
||||||
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
|
"NEXT_PUBLIC_STRIPE_FREE_PLAN_ID",
|
||||||
|
"NEXT_PUBLIC_DISABLE_SIGNUP",
|
||||||
"NEXT_PRIVATE_DATABASE_URL",
|
"NEXT_PRIVATE_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
"NEXT_PRIVATE_DIRECT_DATABASE_URL",
|
||||||
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
"NEXT_PRIVATE_GOOGLE_CLIENT_ID",
|
||||||
|
|||||||
Reference in New Issue
Block a user