mirror of
https://github.com/documenso/documenso.git
synced 2025-11-17 02:01:33 +10:00
Merge branch 'feat/refresh' into date-format-setting
This commit is contained in:
4
packages/app-tests/.gitignore
vendored
Normal file
4
packages/app-tests/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
55
packages/app-tests/e2e/test-auth-flow.spec.ts
Normal file
55
packages/app-tests/e2e/test-auth-flow.spec.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { type Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
/*
|
||||
Using them sequentially so the 2nd test
|
||||
uses the details from the 1st (registration) test
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const username = process.env.E2E_TEST_AUTHENTICATE_USERNAME;
|
||||
const email = process.env.E2E_TEST_AUTHENTICATE_USER_EMAIL;
|
||||
const password = process.env.E2E_TEST_AUTHENTICATE_USER_PASSWORD;
|
||||
|
||||
test('user can sign up with email and password', async ({ page }: { page: Page }) => {
|
||||
await page.goto('/signup');
|
||||
await page.getByLabel('Name').fill(username);
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
await expect(page).toHaveURL('/documents');
|
||||
});
|
||||
|
||||
test('user can login with user and password', async ({ page }: { page: Page }) => {
|
||||
await page.goto('/signin');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
await expect(page).toHaveURL('/documents');
|
||||
});
|
||||
|
||||
test.afterAll('Teardown', async () => {
|
||||
try {
|
||||
await deleteUser({ email });
|
||||
} catch (e) {
|
||||
throw new Error(`Error deleting user: ${e}`);
|
||||
}
|
||||
});
|
||||
21
packages/app-tests/package.json
Normal file
21
packages/app-tests/package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@documenso/app-tests",
|
||||
"version": "1.0.0",
|
||||
"license": "to-update",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test:dev": "playwright test",
|
||||
"test:e2e": "start-server-and-test \"(cd ../../apps/web && npm run start)\" http://localhost:3000 \"playwright test\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.18.1",
|
||||
"@types/node": "^20.8.2",
|
||||
"@documenso/web": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"start-server-and-test": "^2.0.1"
|
||||
}
|
||||
}
|
||||
77
packages/app-tests/playwright.config.ts
Normal file
77
packages/app-tests/playwright.config.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://127.0.0.1:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
||||
@ -14,6 +14,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*"
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"react": "18.2.0",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
28
packages/ee/server-only/limits/client.ts
Normal file
28
packages/ee/server-only/limits/client.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { APP_BASE_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
import { FREE_PLAN_LIMITS } from './constants';
|
||||
import { TLimitsResponseSchema, ZLimitsResponseSchema } from './schema';
|
||||
|
||||
export type GetLimitsOptions = {
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export const getLimits = async ({ headers }: GetLimitsOptions = {}) => {
|
||||
const requestHeaders = headers ?? {};
|
||||
|
||||
const url = new URL(`${APP_BASE_URL}/api/limits`);
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
})
|
||||
.then(async (res) => res.json())
|
||||
.then((res) => ZLimitsResponseSchema.parse(res))
|
||||
.catch((_err) => {
|
||||
return {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
} satisfies TLimitsResponseSchema;
|
||||
});
|
||||
};
|
||||
11
packages/ee/server-only/limits/constants.ts
Normal file
11
packages/ee/server-only/limits/constants.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { TLimitsSchema } from './schema';
|
||||
|
||||
export const FREE_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: 5,
|
||||
recipients: 10,
|
||||
};
|
||||
|
||||
export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||
documents: Infinity,
|
||||
recipients: Infinity,
|
||||
};
|
||||
6
packages/ee/server-only/limits/errors.ts
Normal file
6
packages/ee/server-only/limits/errors.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export const ERROR_CODES: Record<string, string> = {
|
||||
UNAUTHORIZED: 'You must be logged in to access this resource',
|
||||
USER_FETCH_FAILED: 'An error occurred while fetching your user account',
|
||||
SUBSCRIPTION_FETCH_FAILED: 'An error occurred while fetching your subscription',
|
||||
UNKNOWN: 'An unknown error occurred',
|
||||
};
|
||||
37
packages/ee/server-only/limits/handler.ts
Normal file
37
packages/ee/server-only/limits/handler.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { TLimitsErrorResponseSchema, TLimitsResponseSchema } from './schema';
|
||||
import { getServerLimits } from './server';
|
||||
|
||||
export const limitsHandler = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<TLimitsResponseSchema | TLimitsErrorResponseSchema>,
|
||||
) => {
|
||||
try {
|
||||
const token = await getToken({ req });
|
||||
|
||||
const limits = await getServerLimits({ email: token?.email });
|
||||
|
||||
return res.status(200).json(limits);
|
||||
} catch (err) {
|
||||
console.error('error', err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
const status = match(err.message)
|
||||
.with(ERROR_CODES.UNAUTHORIZED, () => 401)
|
||||
.otherwise(() => 500);
|
||||
|
||||
return res.status(status).json({
|
||||
error: ERROR_CODES[err.message] ?? ERROR_CODES.UNKNOWN,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: ERROR_CODES.UNKNOWN,
|
||||
});
|
||||
}
|
||||
};
|
||||
67
packages/ee/server-only/limits/provider/client.tsx
Normal file
67
packages/ee/server-only/limits/provider/client.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { equals } from 'remeda';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import { FREE_PLAN_LIMITS } from '../constants';
|
||||
import { TLimitsResponseSchema } from '../schema';
|
||||
|
||||
export type LimitsContextValue = TLimitsResponseSchema;
|
||||
|
||||
const LimitsContext = createContext<LimitsContextValue | null>(null);
|
||||
|
||||
export const useLimits = () => {
|
||||
const limits = useContext(LimitsContext);
|
||||
|
||||
if (!limits) {
|
||||
throw new Error('useLimits must be used within a LimitsProvider');
|
||||
}
|
||||
|
||||
return limits;
|
||||
};
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
initialValue?: LimitsContextValue;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LimitsProvider = ({ initialValue, children }: LimitsProviderProps) => {
|
||||
const defaultValue: TLimitsResponseSchema = {
|
||||
quota: FREE_PLAN_LIMITS,
|
||||
remaining: FREE_PLAN_LIMITS,
|
||||
};
|
||||
|
||||
const [limits, setLimits] = useState(() => initialValue ?? defaultValue);
|
||||
|
||||
const refreshLimits = async () => {
|
||||
const newLimits = await getLimits();
|
||||
|
||||
setLimits((oldLimits) => {
|
||||
if (equals(oldLimits, newLimits)) {
|
||||
return oldLimits;
|
||||
}
|
||||
|
||||
return newLimits;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshLimits();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = () => {
|
||||
void refreshLimits();
|
||||
};
|
||||
|
||||
window.addEventListener('focus', onFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <LimitsContext.Provider value={limits}>{children}</LimitsContext.Provider>;
|
||||
};
|
||||
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
18
packages/ee/server-only/limits/provider/server.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
'use server';
|
||||
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { getLimits } from '../client';
|
||||
import { LimitsProvider as ClientLimitsProvider } from './client';
|
||||
|
||||
export type LimitsProviderProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LimitsProvider = async ({ children }: LimitsProviderProps) => {
|
||||
const requestHeaders = Object.fromEntries(headers().entries());
|
||||
|
||||
const limits = await getLimits({ headers: requestHeaders });
|
||||
|
||||
return <ClientLimitsProvider initialValue={limits}>{children}</ClientLimitsProvider>;
|
||||
};
|
||||
28
packages/ee/server-only/limits/schema.ts
Normal file
28
packages/ee/server-only/limits/schema.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Not proud of the below but it's a way to deal with Infinity when returning JSON.
|
||||
export const ZLimitsSchema = z.object({
|
||||
documents: z
|
||||
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
|
||||
.optional()
|
||||
.default(0),
|
||||
recipients: z
|
||||
.preprocess((v) => (v === null ? Infinity : Number(v)), z.number())
|
||||
.optional()
|
||||
.default(0),
|
||||
});
|
||||
|
||||
export type TLimitsSchema = z.infer<typeof ZLimitsSchema>;
|
||||
|
||||
export const ZLimitsResponseSchema = z.object({
|
||||
quota: ZLimitsSchema,
|
||||
remaining: ZLimitsSchema,
|
||||
});
|
||||
|
||||
export type TLimitsResponseSchema = z.infer<typeof ZLimitsResponseSchema>;
|
||||
|
||||
export const ZLimitsErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export type TLimitsErrorResponseSchema = z.infer<typeof ZLimitsErrorResponseSchema>;
|
||||
80
packages/ee/server-only/limits/server.ts
Normal file
80
packages/ee/server-only/limits/server.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { ZLimitsSchema } from './schema';
|
||||
|
||||
export type GetServerLimitsOptions = {
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return {
|
||||
quota: SELFHOSTED_PLAN_LIMITS,
|
||||
remaining: SELFHOSTED_PLAN_LIMITS,
|
||||
};
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
throw new Error(ERROR_CODES.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
include: {
|
||||
Subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(ERROR_CODES.USER_FETCH_FAILED);
|
||||
}
|
||||
|
||||
let quota = 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.
|
||||
if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) {
|
||||
const { product } = await stripe.prices
|
||||
.retrieve(user.Subscription.priceId, {
|
||||
expand: ['product'],
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
if (typeof product === 'string') {
|
||||
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
|
||||
}
|
||||
|
||||
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
|
||||
remaining = structuredClone(quota);
|
||||
}
|
||||
|
||||
const documents = await prisma.document.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
createdAt: {
|
||||
gte: DateTime.utc().startOf('month').toJSDate(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
remaining.documents = Math.max(remaining.documents - documents, 0);
|
||||
|
||||
return {
|
||||
quota,
|
||||
remaining,
|
||||
};
|
||||
};
|
||||
32
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
32
packages/ee/server-only/stripe/get-checkout-session.ts
Normal file
@ -0,0 +1,32 @@
|
||||
'use server';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetCheckoutSessionOptions = {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
returnUrl: string;
|
||||
};
|
||||
|
||||
export const getCheckoutSession = async ({
|
||||
customerId,
|
||||
priceId,
|
||||
returnUrl,
|
||||
}: GetCheckoutSessionOptions) => {
|
||||
'use server';
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${returnUrl}?success=true`,
|
||||
cancel_url: `${returnUrl}?canceled=true`,
|
||||
});
|
||||
|
||||
return session.url;
|
||||
};
|
||||
19
packages/ee/server-only/stripe/get-customer.ts
Normal file
19
packages/ee/server-only/stripe/get-customer.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export const getStripeCustomerByEmail = async (email: string) => {
|
||||
const foundStripeCustomers = await stripe.customers.list({
|
||||
email,
|
||||
});
|
||||
|
||||
return foundStripeCustomers.data[0] ?? null;
|
||||
};
|
||||
|
||||
export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||
try {
|
||||
const stripeCustomer = await stripe.customers.retrieve(stripeCustomerId);
|
||||
|
||||
return !stripeCustomer.deleted ? stripeCustomer : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
49
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
49
packages/ee/server-only/stripe/get-prices-by-interval.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
// Utility type to handle usage of the `expand` option.
|
||||
type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
||||
|
||||
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
|
||||
|
||||
export const getPricesByInterval = async () => {
|
||||
let { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
prices = prices.filter((price) => {
|
||||
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = price.product as Stripe.Product;
|
||||
|
||||
// Filter out prices for products that are not active.
|
||||
return product.active;
|
||||
});
|
||||
|
||||
const intervals: PriceIntervals = {
|
||||
day: [],
|
||||
week: [],
|
||||
month: [],
|
||||
year: [],
|
||||
};
|
||||
|
||||
// Add each price to the correct interval.
|
||||
for (const price of prices) {
|
||||
if (price.recurring?.interval) {
|
||||
// We use `expand` to get the product, but it's not typed as part of the Price type.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
intervals[price.recurring.interval].push(price as PriceWithProduct);
|
||||
}
|
||||
}
|
||||
|
||||
// Order all prices by unit_amount.
|
||||
intervals.day.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.week.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.month.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
intervals.year.sort((a, b) => Number(a.unit_amount) - Number(b.unit_amount));
|
||||
|
||||
return intervals;
|
||||
};
|
||||
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
17
packages/ee/server-only/stripe/get-product-by-price-id.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export type GetProductByPriceIdOptions = {
|
||||
priceId: string;
|
||||
};
|
||||
|
||||
export const getProductByPriceId = async ({ priceId }: GetProductByPriceIdOptions) => {
|
||||
const { product } = await stripe.prices.retrieve(priceId, {
|
||||
expand: ['product'],
|
||||
});
|
||||
|
||||
if (typeof product === 'string' || 'deleted' in product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
return product;
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZEarlyAdopterCheckoutMetadataSchema = z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
signatureText: z.string(),
|
||||
signatureDataUrl: z.string().optional(),
|
||||
source: z.literal('marketing'),
|
||||
});
|
||||
|
||||
export type TEarlyAdopterCheckoutMetadataSchema = z.infer<
|
||||
typeof ZEarlyAdopterCheckoutMetadataSchema
|
||||
>;
|
||||
269
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
269
packages/ee/server-only/stripe/webhook/handler.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { buffer } from 'micro';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { onEarlyAdoptersCheckout } from './on-early-adopters-checkout';
|
||||
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const stripeWebhookHandler = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<StripeWebhookResponse>,
|
||||
) => {
|
||||
try {
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
if (!isBillingEnabled) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Billing is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const signature =
|
||||
typeof req.headers['stripe-signature'] === 'string' ? req.headers['stripe-signature'] : '';
|
||||
|
||||
if (!signature) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'No signature found in request',
|
||||
});
|
||||
}
|
||||
|
||||
const body = await buffer(req);
|
||||
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
process.env.NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
|
||||
await match(event.type)
|
||||
.with('checkout.session.completed', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
if (session.metadata?.source === 'marketing') {
|
||||
await onEarlyAdoptersCheckout({ session });
|
||||
}
|
||||
|
||||
const customerId =
|
||||
typeof session.customer === 'string' ? session.customer : session.customer?.id;
|
||||
|
||||
// Attempt to get the user ID from the client reference id.
|
||||
let userId = Number(session.client_reference_id);
|
||||
|
||||
// If the user ID is not found, attempt to get it from the Stripe customer metadata.
|
||||
if (!userId && customerId) {
|
||||
const customer = await stripe.customers.retrieve(customerId);
|
||||
|
||||
if (!customer.deleted) {
|
||||
userId = Number(customer.metadata.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, attempt to get the user ID from the subscription within the database.
|
||||
if (!userId && customerId) {
|
||||
const result = await prisma.subscription.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
userId = result.userId;
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
typeof session.subscription === 'string'
|
||||
? session.subscription
|
||||
: session.subscription?.id;
|
||||
|
||||
if (!subscriptionId || Number.isNaN(userId)) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid session',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
await onSubscriptionUpdated({ userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.updated', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.with('invoice.payment_succeeded', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
if (invoice.billing_reason !== 'subscription_cycle') {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
}
|
||||
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription?.id;
|
||||
|
||||
if (!customerId || !subscriptionId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid invoice',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.with('invoice.payment_failed', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
|
||||
const customerId =
|
||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||
|
||||
const subscriptionId =
|
||||
typeof invoice.subscription === 'string'
|
||||
? invoice.subscription
|
||||
: invoice.subscription?.id;
|
||||
|
||||
if (!customerId || !subscriptionId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Invalid invoice',
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.deleted', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await onSubscriptionDeleted({ subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
})
|
||||
.otherwise(() => {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,138 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { hashSync } from '@documenso/lib/server-only/auth/hash';
|
||||
import { sealDocument } from '@documenso/lib/server-only/document/seal-document';
|
||||
import { redis } from '@documenso/lib/server-only/redis';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { alphaid, nanoid } from '@documenso/lib/universal/id';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import {
|
||||
DocumentStatus,
|
||||
FieldType,
|
||||
ReadStatus,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
} from '@documenso/prisma/client';
|
||||
|
||||
import { ZEarlyAdopterCheckoutMetadataSchema } from './early-adopter-checkout-metadata';
|
||||
|
||||
export type OnEarlyAdoptersCheckoutOptions = {
|
||||
session: Stripe.Checkout.Session;
|
||||
};
|
||||
|
||||
export const onEarlyAdoptersCheckout = async ({ session }: OnEarlyAdoptersCheckoutOptions) => {
|
||||
try {
|
||||
const safeMetadata = ZEarlyAdopterCheckoutMetadataSchema.safeParse(session.metadata);
|
||||
|
||||
if (!safeMetadata.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, name, signatureText, signatureDataUrl: signatureDataUrlRef } = safeMetadata.data;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tempPassword = nanoid(12);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
password: hashSync(tempPassword),
|
||||
signature: signatureDataUrlRef,
|
||||
},
|
||||
});
|
||||
|
||||
const customerId =
|
||||
typeof session.customer === 'string' ? session.customer : session.customer?.id;
|
||||
|
||||
if (customerId) {
|
||||
await stripe.customers.update(customerId, {
|
||||
metadata: {
|
||||
userId: newUser.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await redis.set(`user:${newUser.id}:temp-password`, tempPassword, {
|
||||
// expire in 1 week
|
||||
ex: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
const signatureDataUrl = await redis.get<string>(`signature:${session.client_reference_id}`);
|
||||
|
||||
const documentBuffer = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/documenso-supporter-pledge.pdf`,
|
||||
).then(async (res) => res.arrayBuffer());
|
||||
|
||||
const { id: documentDataId } = await putFile({
|
||||
name: 'Documenso Supporter Pledge.pdf',
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(documentBuffer),
|
||||
});
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
title: 'Documenso Supporter Pledge.pdf',
|
||||
status: DocumentStatus.COMPLETED,
|
||||
userId: newUser.id,
|
||||
documentDataId,
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
token: alphaid(),
|
||||
readStatus: ReadStatus.OPENED,
|
||||
sendStatus: SendStatus.SENT,
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.field.create({
|
||||
data: {
|
||||
type: FieldType.SIGNATURE,
|
||||
recipientId: recipient.id,
|
||||
documentId: document.id,
|
||||
page: 1,
|
||||
positionX: 12.2781,
|
||||
positionY: 81.5789,
|
||||
height: 6.8649,
|
||||
width: 29.5857,
|
||||
inserted: true,
|
||||
customText: '',
|
||||
|
||||
Signature: {
|
||||
create: {
|
||||
typedSignature: signatureDataUrl ? null : signatureText || name,
|
||||
signatureImageAsBase64: signatureDataUrl,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await sealDocument({
|
||||
documentId: document.id,
|
||||
sendEmail: false,
|
||||
});
|
||||
} catch (error) {
|
||||
// We don't want to break the checkout process if something goes wrong here.
|
||||
// This is an additive experience for early adopters, breaking their ability
|
||||
// join would be far worse than not having a signed pledge.
|
||||
console.error('early-supporter-error', error);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,21 @@
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type OnSubscriptionDeletedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
userId: number;
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionUpdated = async ({
|
||||
userId,
|
||||
subscription,
|
||||
}: OnSubscriptionUpdatedOptions) => {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
create: {
|
||||
customerId,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
userId,
|
||||
},
|
||||
update: {
|
||||
customerId,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1 +1 @@
|
||||
export { render, renderAsync } from '@react-email/components';
|
||||
export { render } from '@react-email/components';
|
||||
|
||||
@ -32,7 +32,7 @@ export const DocumentInviteEmailTemplate = ({
|
||||
assetBaseUrl = 'http://localhost:3002',
|
||||
customBody,
|
||||
}: DocumentInviteEmailTemplateProps) => {
|
||||
const previewText = `Completed Document`;
|
||||
const previewText = `${inviterName} has invited you to sign ${documentName}`;
|
||||
|
||||
const getAssetUrl = (path: string) => {
|
||||
return new URL(path, assetBaseUrl).toString();
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||
"@typescript-eslint/parser": "^5.59.2",
|
||||
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||
"@typescript-eslint/parser": "6.8.0",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-next": "13.4.19",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
@ -16,6 +16,6 @@
|
||||
"eslint-plugin-package-json": "^0.1.4",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
53
packages/lib/client-only/hooks/use-copy-share-link.ts
Normal file
53
packages/lib/client-only/hooks/use-copy-share-link.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { TCreateOrGetShareLinkMutationSchema } from '@documenso/trpc/server/share-link-router/schema';
|
||||
|
||||
import { useCopyToClipboard } from './use-copy-to-clipboard';
|
||||
|
||||
export type UseCopyShareLinkOptions = {
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
};
|
||||
|
||||
export function useCopyShareLink({ onSuccess, onError }: UseCopyShareLinkOptions) {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const { mutateAsync: createOrGetShareLink, isLoading: isCreatingShareLink } =
|
||||
trpc.shareLink.createOrGetShareLink.useMutation();
|
||||
|
||||
/**
|
||||
* Copy a newly created, or pre-existing share link to the user's clipboard.
|
||||
*
|
||||
* @param payload The payload to create or get a share link.
|
||||
*/
|
||||
const createAndCopyShareLink = async (payload: TCreateOrGetShareLinkMutationSchema) => {
|
||||
const valueToCopy = createOrGetShareLink(payload).then(
|
||||
(result) => `${window.location.origin}/share/${result.slug}`,
|
||||
);
|
||||
|
||||
await copyShareLink(valueToCopy);
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy a share link to the user's clipboard.
|
||||
*
|
||||
* @param shareLink Either the share link itself or a promise that returns a shared link.
|
||||
*/
|
||||
const copyShareLink = async (shareLink: Promise<string> | string) => {
|
||||
try {
|
||||
const isCopySuccess = await copyToClipboard(shareLink);
|
||||
if (!isCopySuccess) {
|
||||
throw new Error('Copy to clipboard failed');
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} catch (e) {
|
||||
onError?.();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
createAndCopyShareLink,
|
||||
copyShareLink,
|
||||
isCopyingShareLink: isCreatingShareLink,
|
||||
};
|
||||
}
|
||||
60
packages/lib/client-only/hooks/use-copy-to-clipboard.ts
Normal file
60
packages/lib/client-only/hooks/use-copy-to-clipboard.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export type CopiedValue = string | null;
|
||||
export type CopyFn = (_text: CopyValue, _blobType?: string) => Promise<boolean>;
|
||||
|
||||
type CopyValue = Promise<string> | string;
|
||||
|
||||
export function useCopyToClipboard(): [CopiedValue, CopyFn] {
|
||||
const [copiedText, setCopiedText] = useState<CopiedValue>(null);
|
||||
|
||||
const copy: CopyFn = async (text, blobType = 'text/plain') => {
|
||||
if (!navigator?.clipboard) {
|
||||
console.warn('Clipboard not supported');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isClipboardApiSupported = Boolean(typeof ClipboardItem && navigator.clipboard.write);
|
||||
|
||||
// Try to save to clipboard then save it in the state if worked
|
||||
try {
|
||||
isClipboardApiSupported
|
||||
? await handleClipboardApiCopy(text, blobType)
|
||||
: await handleWriteTextCopy(text);
|
||||
|
||||
setCopiedText(await text);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Copy failed', error);
|
||||
setCopiedText(null);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle copying values to the clipboard using the ClipboardItem API.
|
||||
*
|
||||
* Works in all browsers except FireFox.
|
||||
*
|
||||
* https://caniuse.com/mdn-api_clipboarditem
|
||||
*/
|
||||
const handleClipboardApiCopy = async (value: CopyValue, blobType = 'text/plain') => {
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blobType]: value })]);
|
||||
} catch (e) {
|
||||
// Fallback attempt.
|
||||
await handleWriteTextCopy(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle copying values to the clipboard using `writeText`.
|
||||
*
|
||||
* Works in all browsers except Safari for async values.
|
||||
*/
|
||||
const handleWriteTextCopy = async (value: CopyValue) => {
|
||||
await navigator.clipboard.writeText(await value);
|
||||
};
|
||||
|
||||
return [copiedText, copy];
|
||||
}
|
||||
@ -60,26 +60,17 @@ export const calculateTextScaleSize = (
|
||||
*/
|
||||
export function useElementScaleSize(
|
||||
container: { width: number; height: number },
|
||||
child: RefObject<HTMLElement | null>,
|
||||
text: string,
|
||||
fontSize: number,
|
||||
fontFamily: string,
|
||||
) {
|
||||
const [scalingFactor, setScalingFactor] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!child.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleSize = calculateTextScaleSize(
|
||||
container,
|
||||
child.current.innerText,
|
||||
`${fontSize}px`,
|
||||
fontFamily,
|
||||
);
|
||||
const scaleSize = calculateTextScaleSize(container, text, `${fontSize}px`, fontFamily);
|
||||
|
||||
setScalingFactor(scaleSize);
|
||||
}, [child, container, fontFamily, fontSize]);
|
||||
}, [text, container, fontFamily, fontSize]);
|
||||
|
||||
return scalingFactor;
|
||||
}
|
||||
|
||||
13
packages/lib/constants/toast.ts
Normal file
13
packages/lib/constants/toast.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const TOAST_DOCUMENT_SHARE_SUCCESS: Toast = {
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The sharing link has been copied to your clipboard.',
|
||||
} as const;
|
||||
|
||||
export const TOAST_DOCUMENT_SHARE_ERROR: Toast = {
|
||||
variant: 'destructive',
|
||||
title: 'Something went wrong',
|
||||
description: 'The sharing link could not be created at this time. Please try again.',
|
||||
duration: 5000,
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||
import { compare } from 'bcrypt';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AuthOptions, Session, User } from 'next-auth';
|
||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||
import GoogleProvider, { GoogleProfile } from 'next-auth/providers/google';
|
||||
@ -54,6 +55,7 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
clientId: process.env.NEXT_PRIVATE_GOOGLE_CLIENT_ID ?? '',
|
||||
clientSecret: process.env.NEXT_PRIVATE_GOOGLE_CLIENT_SECRET ?? '',
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
|
||||
profile(profile) {
|
||||
return {
|
||||
id: Number(profile.sub),
|
||||
@ -65,27 +67,50 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (!token.email) {
|
||||
throw new Error('No email in token');
|
||||
const merged = {
|
||||
...token,
|
||||
...user,
|
||||
};
|
||||
|
||||
if (!merged.email) {
|
||||
const userId = Number(merged.id ?? token.sub);
|
||||
|
||||
const retrieved = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!retrieved) {
|
||||
return token;
|
||||
}
|
||||
|
||||
merged.id = retrieved.id;
|
||||
merged.name = retrieved.name;
|
||||
merged.email = retrieved.email;
|
||||
}
|
||||
|
||||
const retrievedUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: token.email,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!merged.lastSignedIn ||
|
||||
DateTime.fromISO(merged.lastSignedIn).plus({ hours: 1 }) <= DateTime.now()
|
||||
) {
|
||||
merged.lastSignedIn = new Date().toISOString();
|
||||
|
||||
if (!retrievedUser) {
|
||||
return {
|
||||
...token,
|
||||
id: user.id,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: Number(merged.id),
|
||||
},
|
||||
data: {
|
||||
lastSignedIn: merged.lastSignedIn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: retrievedUser.id,
|
||||
name: retrievedUser.name,
|
||||
email: retrievedUser.email,
|
||||
id: merged.id,
|
||||
name: merged.name,
|
||||
email: merged.email,
|
||||
lastSignedIn: merged.lastSignedIn,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { Role, User } from '@documenso/prisma/client';
|
||||
|
||||
const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||
|
||||
export { isAdmin };
|
||||
export const isAdmin = (user: User) => user.roles.includes(Role.ADMIN);
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.410.0",
|
||||
"@aws-sdk/cloudfront-signer": "^3.410.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.410.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.410.0",
|
||||
"@documenso/email": "*",
|
||||
@ -28,12 +29,14 @@
|
||||
"bcrypt": "^5.1.0",
|
||||
"luxon": "^3.4.0",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.19",
|
||||
"next-auth": "4.22.3",
|
||||
"next": "14.0.0",
|
||||
"next-auth": "4.24.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.2.0",
|
||||
"remeda": "^1.27.1",
|
||||
"stripe": "^12.7.0",
|
||||
"ts-pattern": "^5.0.5"
|
||||
"ts-pattern": "^5.0.5",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
|
||||
55
packages/lib/server-only/admin/get-all-documents.ts
Normal file
55
packages/lib/server-only/admin/get-all-documents.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
export interface FindDocumentsOptions {
|
||||
term?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export const findDocuments = async ({ term, page = 1, perPage = 10 }: FindDocumentsOptions) => {
|
||||
const termFilters: Prisma.DocumentWhereInput | undefined = !term
|
||||
? undefined
|
||||
: {
|
||||
title: {
|
||||
contains: term,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
};
|
||||
|
||||
const [data, count] = await Promise.all([
|
||||
prisma.document.findMany({
|
||||
where: {
|
||||
...termFilters,
|
||||
},
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
include: {
|
||||
User: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
Recipient: true,
|
||||
},
|
||||
}),
|
||||
prisma.document.count({
|
||||
where: {
|
||||
...termFilters,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
count,
|
||||
currentPage: Math.max(page, 1),
|
||||
perPage,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
};
|
||||
};
|
||||
13
packages/lib/server-only/admin/get-all-subscriptions.ts
Normal file
13
packages/lib/server-only/admin/get-all-subscriptions.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export const findSubscriptions = async () => {
|
||||
return prisma.subscription.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
periodEnd: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -9,9 +9,7 @@ export const getUsersWithSubscriptionsCount = async () => {
|
||||
return await prisma.user.count({
|
||||
where: {
|
||||
Subscription: {
|
||||
some: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
28
packages/lib/server-only/admin/update-user.ts
Normal file
28
packages/lib/server-only/admin/update-user.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Role } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdateUserOptions = {
|
||||
id: number;
|
||||
name: string | null | undefined;
|
||||
email: string | undefined;
|
||||
roles: Role[] | undefined;
|
||||
};
|
||||
|
||||
export const updateUser = async ({ id, name, email, roles }: UpdateUserOptions) => {
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.user.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
roles,
|
||||
},
|
||||
});
|
||||
};
|
||||
13
packages/lib/server-only/document/delete-draft-document.ts
Normal file
13
packages/lib/server-only/document/delete-draft-document.ts
Normal file
@ -0,0 +1,13 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
|
||||
export type DeleteDraftDocumentOptions = {
|
||||
id: number;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const deleteDraftDocument = async ({ id, userId }: DeleteDraftDocumentOptions) => {
|
||||
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } });
|
||||
};
|
||||
@ -14,9 +14,10 @@ import { sendCompletedEmail } from './send-completed-email';
|
||||
|
||||
export type SealDocumentOptions = {
|
||||
documentId: number;
|
||||
sendEmail?: boolean;
|
||||
};
|
||||
|
||||
export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumentOptions) => {
|
||||
'use server';
|
||||
|
||||
const document = await prisma.document.findFirstOrThrow({
|
||||
@ -91,5 +92,7 @@ export const sealDocument = async ({ documentId }: SealDocumentOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
await sendCompletedEmail({ documentId });
|
||||
if (sendEmail) {
|
||||
await sendCompletedEmail({ documentId });
|
||||
}
|
||||
};
|
||||
|
||||
@ -5,6 +5,8 @@ import { render } from '@documenso/email/render';
|
||||
import { DocumentCompletedEmailTemplate } from '@documenso/email/templates/document-completed';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getFile } from '../../universal/upload/get-file';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
}
|
||||
@ -15,6 +17,7 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
||||
id: documentId,
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
@ -27,6 +30,8 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
const buffer = await getFile(document.documentData);
|
||||
|
||||
await Promise.all([
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name, token } = recipient;
|
||||
@ -51,6 +56,12 @@ export const sendCompletedEmail = async ({ documentId }: SendDocumentOptions) =>
|
||||
subject: 'Signing Complete!',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
attachments: [
|
||||
{
|
||||
filename: document.title,
|
||||
content: Buffer.from(buffer),
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
9
packages/lib/server-only/http/to-next-request.ts
Normal file
9
packages/lib/server-only/http/to-next-request.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const toNextRequest = (req: Request) => {
|
||||
const headers = Object.fromEntries(req.headers.entries());
|
||||
|
||||
return new NextRequest(req, {
|
||||
headers: headers,
|
||||
});
|
||||
};
|
||||
28
packages/lib/server-only/http/with-swr.ts
Normal file
28
packages/lib/server-only/http/with-swr.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextApiResponse } from 'next';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
type NarrowedResponse<T> = T extends NextResponse
|
||||
? NextResponse
|
||||
: T extends NextApiResponse<infer U>
|
||||
? NextApiResponse<U>
|
||||
: never;
|
||||
|
||||
export const withStaleWhileRevalidate = <T>(
|
||||
res: NarrowedResponse<T>,
|
||||
cacheInSeconds = 60,
|
||||
staleCacheInSeconds = 300,
|
||||
) => {
|
||||
if ('headers' in res) {
|
||||
res.headers.set(
|
||||
'Cache-Control',
|
||||
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
|
||||
);
|
||||
} else {
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
`public, s-maxage=${cacheInSeconds}, stale-while-revalidate=${staleCacheInSeconds}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetRecipientSignaturesOptions = {
|
||||
recipientId: number;
|
||||
};
|
||||
|
||||
export const getRecipientSignatures = async ({ recipientId }: GetRecipientSignaturesOptions) => {
|
||||
return await prisma.signature.findMany({
|
||||
where: {
|
||||
Field: {
|
||||
recipientId,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
/// <reference types="./stripe.d.ts" />
|
||||
import Stripe from 'stripe';
|
||||
|
||||
export const stripe = new Stripe(process.env.NEXT_PRIVATE_STRIPE_API_KEY ?? '', {
|
||||
|
||||
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
Normal file
7
packages/lib/server-only/stripe/stripe.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
declare module 'stripe' {
|
||||
namespace Stripe {
|
||||
interface Product {
|
||||
features?: Array<{ name: string }>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ export type GetSubscriptionByUserIdOptions = {
|
||||
};
|
||||
|
||||
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
|
||||
return prisma.subscription.findFirst({
|
||||
return await prisma.subscription.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
|
||||
25
packages/lib/server-only/user/delete-user.ts
Normal file
25
packages/lib/server-only/user/delete-user.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type DeleteUserOptions = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const deleteUser = async ({ email }: DeleteUserOptions) => {
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
contains: email,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User with email ${email} not found`);
|
||||
}
|
||||
|
||||
return await prisma.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
57
packages/lib/server-only/user/get-all-users.ts
Normal file
57
packages/lib/server-only/user/get-all-users.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@documenso/prisma/client';
|
||||
|
||||
type GetAllUsersProps = {
|
||||
username: string;
|
||||
email: string;
|
||||
page: number;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export const findUsers = async ({
|
||||
username = '',
|
||||
email = '',
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: GetAllUsersProps) => {
|
||||
const whereClause = Prisma.validator<Prisma.UserWhereInput>()({
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: username,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
email: {
|
||||
contains: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [users, count] = await Promise.all([
|
||||
await prisma.user.findMany({
|
||||
include: {
|
||||
Subscription: true,
|
||||
Document: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: whereClause,
|
||||
skip: Math.max(page - 1, 0) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
await prisma.user.count({
|
||||
where: whereClause,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
totalPages: Math.ceil(count / perPage),
|
||||
};
|
||||
};
|
||||
@ -7,9 +7,14 @@ import { SALT_ROUNDS } from '../../constants/auth';
|
||||
export type UpdatePasswordOptions = {
|
||||
userId: number;
|
||||
password: string;
|
||||
currentPassword: string;
|
||||
};
|
||||
|
||||
export const updatePassword = async ({ userId, password }: UpdatePasswordOptions) => {
|
||||
export const updatePassword = async ({
|
||||
userId,
|
||||
password,
|
||||
currentPassword,
|
||||
}: UpdatePasswordOptions) => {
|
||||
// Existence check
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
@ -17,23 +22,29 @@ export const updatePassword = async ({ userId, password }: UpdatePasswordOptions
|
||||
},
|
||||
});
|
||||
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
if (user.password) {
|
||||
// Compare the new password with the old password
|
||||
const isSamePassword = await compare(password, user.password);
|
||||
|
||||
if (isSamePassword) {
|
||||
throw new Error('Your new password cannot be the same as your old password.');
|
||||
}
|
||||
if (!user.password) {
|
||||
throw new Error('User has no password');
|
||||
}
|
||||
|
||||
const isCurrentPasswordValid = await compare(currentPassword, user.password);
|
||||
if (!isCurrentPasswordValid) {
|
||||
throw new Error('Current password is incorrect.');
|
||||
}
|
||||
|
||||
// Compare the new password with the old password
|
||||
const isSamePassword = await compare(password, user.password);
|
||||
if (isSamePassword) {
|
||||
throw new Error('Your new password cannot be the same as your old password.');
|
||||
}
|
||||
|
||||
const hashedNewPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
password: hashedNewPassword,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
1
packages/lib/types/next-auth.d.ts
vendored
1
packages/lib/types/next-auth.d.ts
vendored
@ -19,5 +19,6 @@ declare module 'next-auth/jwt' {
|
||||
id: string | number;
|
||||
name?: string | null;
|
||||
email: string | null;
|
||||
lastSignedIn?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
3
packages/lib/universal/stripe/to-human-price.ts
Normal file
3
packages/lib/universal/stripe/to-human-price.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const toHumanPrice = (price: number) => {
|
||||
return Number(price / 100).toFixed(2);
|
||||
};
|
||||
@ -3,8 +3,6 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
import { getPresignGetUrl } from './server-actions';
|
||||
|
||||
export type GetFileOptions = {
|
||||
type: DocumentDataType;
|
||||
data: string;
|
||||
@ -33,6 +31,8 @@ const getFileFromBytes64 = (data: string) => {
|
||||
};
|
||||
|
||||
const getFileFromS3 = async (key: string) => {
|
||||
const { getPresignGetUrl } = await import('./server-actions');
|
||||
|
||||
const { url } = await getPresignGetUrl(key);
|
||||
|
||||
const response = await fetch(url, {
|
||||
|
||||
@ -4,7 +4,6 @@ import { match } from 'ts-pattern';
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
import { createDocumentData } from '../../server-only/document-data/create-document-data';
|
||||
import { getPresignPostUrl } from './server-actions';
|
||||
|
||||
type File = {
|
||||
name: string;
|
||||
@ -34,6 +33,8 @@ const putFileInDatabase = async (file: File) => {
|
||||
};
|
||||
|
||||
const putFileInS3 = async (file: File) => {
|
||||
const { getPresignPostUrl } = await import('./server-actions');
|
||||
|
||||
const { url, key } = await getPresignPostUrl(file.name, file.type);
|
||||
|
||||
const body = await file.arrayBuffer();
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
import path from 'node:path';
|
||||
|
||||
@ -17,6 +16,8 @@ import { alphaid } from '../id';
|
||||
export const getPresignPostUrl = async (fileName: string, contentType: string) => {
|
||||
const client = getS3Client();
|
||||
|
||||
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
||||
|
||||
const { user } = await getServerComponentSession();
|
||||
|
||||
// Get the basename and extension for the file
|
||||
@ -44,12 +45,14 @@ export const getPresignPostUrl = async (fileName: string, contentType: string) =
|
||||
export const getAbsolutePresignPostUrl = async (key: string) => {
|
||||
const client = getS3Client();
|
||||
|
||||
const { getSignedUrl: getS3SignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
||||
|
||||
const putObjectCommand = new PutObjectCommand({
|
||||
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(client, putObjectCommand, {
|
||||
const url = await getS3SignedUrl(client, putObjectCommand, {
|
||||
expiresIn: ONE_HOUR / ONE_SECOND,
|
||||
});
|
||||
|
||||
@ -57,14 +60,31 @@ export const getAbsolutePresignPostUrl = async (key: string) => {
|
||||
};
|
||||
|
||||
export const getPresignGetUrl = async (key: string) => {
|
||||
if (process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN) {
|
||||
const distributionUrl = new URL(key, `${process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN}`);
|
||||
|
||||
const { getSignedUrl: getCloudfrontSignedUrl } = await import('@aws-sdk/cloudfront-signer');
|
||||
|
||||
const url = getCloudfrontSignedUrl({
|
||||
url: distributionUrl.toString(),
|
||||
keyPairId: `${process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID}`,
|
||||
privateKey: `${process.env.NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS}`,
|
||||
dateLessThan: new Date(Date.now() + ONE_HOUR).toISOString(),
|
||||
});
|
||||
|
||||
return { key, url };
|
||||
}
|
||||
|
||||
const client = getS3Client();
|
||||
|
||||
const { getSignedUrl: getS3SignedUrl } = await import('@aws-sdk/s3-request-presigner');
|
||||
|
||||
const getObjectCommand = new GetObjectCommand({
|
||||
Bucket: process.env.NEXT_PRIVATE_UPLOAD_BUCKET,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(client, getObjectCommand, {
|
||||
const url = await getS3SignedUrl(client, getObjectCommand, {
|
||||
expiresIn: ONE_HOUR / ONE_SECOND,
|
||||
});
|
||||
|
||||
|
||||
@ -3,8 +3,6 @@ import { match } from 'ts-pattern';
|
||||
|
||||
import { DocumentDataType } from '@documenso/prisma/client';
|
||||
|
||||
import { getAbsolutePresignPostUrl } from './server-actions';
|
||||
|
||||
export type UpdateFileOptions = {
|
||||
type: DocumentDataType;
|
||||
oldData: string;
|
||||
@ -40,6 +38,8 @@ const updateFileWithBytes64 = (data: string) => {
|
||||
};
|
||||
|
||||
const updateFileWithS3 = async (key: string, data: string) => {
|
||||
const { getAbsolutePresignPostUrl } = await import('./server-actions');
|
||||
|
||||
const { url } = await getAbsolutePresignPostUrl(key);
|
||||
|
||||
const response = await fetch(url, {
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[userId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
|
||||
- Made the column `customerId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
|
||||
DELETE FROM "Subscription"
|
||||
WHERE "customerId" IS NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Subscription" ADD COLUMN "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
|
||||
ALTER COLUMN "customerId" SET NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");
|
||||
@ -0,0 +1,5 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "DocumentShareLink" DROP CONSTRAINT "DocumentShareLink_documentId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DocumentShareLink" ADD CONSTRAINT "DocumentShareLink_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "lastSignedIn" TIMESTAMP(3) NOT NULL DEFAULT '1970-01-01 00:00:00 +00:00',
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "lastSignedIn" SET DEFAULT CURRENT_TIMESTAMP;
|
||||
@ -0,0 +1,23 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_userId_idx" ON "Document"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_status_idx" ON "Document"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Field_documentId_idx" ON "Field"("documentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Field_recipientId_idx" ON "Field"("recipientId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_documentId_idx" ON "Recipient"("documentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Recipient_token_idx" ON "Recipient"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Signature_recipientId_idx" ON "Signature"("recipientId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||
@ -14,14 +14,16 @@
|
||||
"prisma:seed": "prisma db seed"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node --transpileOnly --skipProject ./seed-database.ts"
|
||||
"seed": "ts-node --transpileOnly --project ./tsconfig.seed.json ./seed-database.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.3.1",
|
||||
"prisma": "5.3.1"
|
||||
"@prisma/client": "5.4.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"dotenv-cli": "^7.3.0",
|
||||
"prisma": "5.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,13 +26,18 @@ model User {
|
||||
password String?
|
||||
source String?
|
||||
signature String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
lastSignedIn DateTime @default(now())
|
||||
roles Role[] @default([USER])
|
||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
Subscription Subscription[]
|
||||
Subscription Subscription?
|
||||
PasswordResetToken PasswordResetToken[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
@ -51,15 +56,16 @@ enum SubscriptionStatus {
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id Int @id @default(autoincrement())
|
||||
status SubscriptionStatus @default(INACTIVE)
|
||||
planId String?
|
||||
priceId String?
|
||||
customerId String?
|
||||
periodEnd DateTime?
|
||||
userId Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
status SubscriptionStatus @default(INACTIVE)
|
||||
planId String?
|
||||
priceId String?
|
||||
customerId String
|
||||
periodEnd DateTime?
|
||||
userId Int @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
cancelAtPeriodEnd Boolean @default(false)
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@ -110,12 +116,14 @@ model Document {
|
||||
Field Field[]
|
||||
ShareLink DocumentShareLink[]
|
||||
documentDataId String
|
||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||
documentData DocumentData @relation(fields: [documentDataId], references: [id], onDelete: Cascade)
|
||||
documentMeta DocumentMeta?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique([documentDataId])
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
enum DocumentDataType {
|
||||
@ -133,11 +141,11 @@ model DocumentData {
|
||||
}
|
||||
|
||||
model DocumentMeta {
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
message String?
|
||||
documentId Int @unique
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
message String?
|
||||
documentId Int @unique
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ReadStatus {
|
||||
@ -171,6 +179,8 @@ model Recipient {
|
||||
Signature Signature[]
|
||||
|
||||
@@unique([documentId, email])
|
||||
@@index([documentId])
|
||||
@@index([token])
|
||||
}
|
||||
|
||||
enum FieldType {
|
||||
@ -197,6 +207,9 @@ model Field {
|
||||
Document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
Recipient Recipient? @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Signature Signature?
|
||||
|
||||
@@index([documentId])
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
||||
model Signature {
|
||||
@ -209,6 +222,8 @@ model Signature {
|
||||
|
||||
Recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
Field Field @relation(fields: [fieldId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
||||
model DocumentShareLink {
|
||||
@ -219,7 +234,7 @@ model DocumentShareLink {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
document Document @relation(fields: [documentId], references: [id])
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([documentId, email])
|
||||
}
|
||||
|
||||
6
packages/prisma/tsconfig.seed.json
Normal file
6
packages/prisma/tsconfig.seed.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext"
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,6 @@
|
||||
"@trpc/react-query": "^10.36.0",
|
||||
"@trpc/server": "^10.36.0",
|
||||
"superjson": "^1.13.1",
|
||||
"zod": "^3.21.4"
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
23
packages/trpc/server/admin-router/router.ts
Normal file
23
packages/trpc/server/admin-router/router.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { updateUser } from '@documenso/lib/server-only/admin/update-user';
|
||||
|
||||
import { adminProcedure, router } from '../trpc';
|
||||
import { ZUpdateProfileMutationByAdminSchema } from './schema';
|
||||
|
||||
export const adminRouter = router({
|
||||
updateUser: adminProcedure
|
||||
.input(ZUpdateProfileMutationByAdminSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, name, email, roles } = input;
|
||||
|
||||
try {
|
||||
return await updateUser({ id, name, email, roles });
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to retrieve the specified account. Please try again.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
13
packages/trpc/server/admin-router/schema.ts
Normal file
13
packages/trpc/server/admin-router/schema.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Role } from '@prisma/client';
|
||||
import z from 'zod';
|
||||
|
||||
export const ZUpdateProfileMutationByAdminSchema = z.object({
|
||||
id: z.number().min(1),
|
||||
name: z.string().nullish(),
|
||||
email: z.string().email().optional(),
|
||||
roles: z.array(z.nativeEnum(Role)).optional(),
|
||||
});
|
||||
|
||||
export type TUpdateProfileMutationByAdminSchema = z.infer<
|
||||
typeof ZUpdateProfileMutationByAdminSchema
|
||||
>;
|
||||
@ -12,12 +12,16 @@ export const authRouter = router({
|
||||
|
||||
return await createUser({ name, email, password, signature });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
let message =
|
||||
'We were unable to create your account. Please review the information you provided and try again.';
|
||||
|
||||
if (err instanceof Error && err.message === 'User already exists') {
|
||||
message = 'User with this email already exists. Please use a different email address.';
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'We were unable to create your account. Please review the information you provided and try again.',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { createDocument } from '@documenso/lib/server-only/document/create-document';
|
||||
import { deleteDraftDocument } from '@documenso/lib/server-only/document/delete-draft-document';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
@ -10,6 +12,7 @@ import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/s
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZCreateDocumentMutationSchema,
|
||||
ZDeleteDraftDocumentMutationSchema,
|
||||
ZGetDocumentByIdQuerySchema,
|
||||
ZGetDocumentByTokenQuerySchema,
|
||||
ZSendDocumentMutationSchema,
|
||||
@ -61,13 +64,25 @@ export const documentRouter = router({
|
||||
try {
|
||||
const { title, documentDataId } = input;
|
||||
|
||||
const { remaining } = await getServerLimits({ email: ctx.user.email });
|
||||
|
||||
if (remaining.documents <= 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'You have reached your document limit for this month. Please upgrade your plan.',
|
||||
});
|
||||
}
|
||||
|
||||
return await createDocument({
|
||||
userId: ctx.user.id,
|
||||
title,
|
||||
documentDataId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err instanceof TRPCError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
@ -76,6 +91,25 @@ export const documentRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
deleteDraftDocument: authenticatedProcedure
|
||||
.input(ZDeleteDraftDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { id } = input;
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
return await deleteDraftDocument({ id, userId });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to delete this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
setRecipientsForDocument: authenticatedProcedure
|
||||
.input(ZSetRecipientsForDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -61,3 +61,9 @@ export const ZSendDocumentMutationSchema = z.object({
|
||||
});
|
||||
|
||||
export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSchema>;
|
||||
|
||||
export const ZDeleteDraftDocumentMutationSchema = z.object({
|
||||
id: z.number().min(1),
|
||||
});
|
||||
|
||||
export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocumentMutationSchema>;
|
||||
|
||||
@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
export const ZSignFieldWithTokenMutationSchema = z.object({
|
||||
token: z.string(),
|
||||
fieldId: z.number(),
|
||||
value: z.string(),
|
||||
value: z.string().trim(),
|
||||
isBase64: z.boolean().optional(),
|
||||
});
|
||||
|
||||
|
||||
@ -1,19 +1,34 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
|
||||
import { resetPassword } from '@documenso/lib/server-only/user/reset-password';
|
||||
import { updatePassword } from '@documenso/lib/server-only/user/update-password';
|
||||
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
|
||||
|
||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import { adminProcedure, authenticatedProcedure, procedure, router } from '../trpc';
|
||||
import {
|
||||
ZForgotPasswordFormSchema,
|
||||
ZResetPasswordFormSchema,
|
||||
ZRetrieveUserByIdQuerySchema,
|
||||
ZUpdatePasswordMutationSchema,
|
||||
ZUpdateProfileMutationSchema,
|
||||
} from './schema';
|
||||
|
||||
export const profileRouter = router({
|
||||
getUser: adminProcedure.input(ZRetrieveUserByIdQuerySchema).query(async ({ input }) => {
|
||||
try {
|
||||
const { id } = input;
|
||||
|
||||
return await getUserById({ id });
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to retrieve the specified account. Please try again.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
updateProfile: authenticatedProcedure
|
||||
.input(ZUpdateProfileMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
@ -40,11 +55,12 @@ export const profileRouter = router({
|
||||
.input(ZUpdatePasswordMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { password } = input;
|
||||
const { password, currentPassword } = input;
|
||||
|
||||
return await updatePassword({
|
||||
userId: ctx.user.id,
|
||||
password,
|
||||
currentPassword,
|
||||
});
|
||||
} catch (err) {
|
||||
let message =
|
||||
|
||||
@ -1,11 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZRetrieveUserByIdQuerySchema = z.object({
|
||||
id: z.number().min(1),
|
||||
});
|
||||
|
||||
export const ZUpdateProfileMutationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
signature: z.string(),
|
||||
});
|
||||
|
||||
export const ZUpdatePasswordMutationSchema = z.object({
|
||||
currentPassword: z.string().min(6),
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
@ -18,6 +23,7 @@ export const ZResetPasswordFormSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TRetrieveUserByIdQuerySchema = z.infer<typeof ZRetrieveUserByIdQuerySchema>;
|
||||
export type TUpdateProfileMutationSchema = z.infer<typeof ZUpdateProfileMutationSchema>;
|
||||
export type TUpdatePasswordMutationSchema = z.infer<typeof ZUpdatePasswordMutationSchema>;
|
||||
export type TForgotPasswordFormSchema = z.infer<typeof ZForgotPasswordFormSchema>;
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { adminRouter } from './admin-router/router';
|
||||
import { authRouter } from './auth-router/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { fieldRouter } from './field-router/router';
|
||||
@ -6,11 +7,14 @@ import { shareLinkRouter } from './share-link-router/router';
|
||||
import { procedure, router } from './trpc';
|
||||
|
||||
export const appRouter = router({
|
||||
hello: procedure.query(() => 'Hello, world!'),
|
||||
health: procedure.query(() => {
|
||||
return { status: 'ok' };
|
||||
}),
|
||||
auth: authRouter,
|
||||
profile: profileRouter,
|
||||
document: documentRouter,
|
||||
field: fieldRouter,
|
||||
admin: adminRouter,
|
||||
shareLink: shareLinkRouter,
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import SuperJSON from 'superjson';
|
||||
|
||||
import { isAdmin } from '@documenso/lib/next-auth/guards/is-admin';
|
||||
|
||||
import { TrpcContext } from './context';
|
||||
|
||||
const t = initTRPC.context<TrpcContext>().create({
|
||||
@ -28,9 +30,37 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||
});
|
||||
});
|
||||
|
||||
export const adminMiddleware = t.middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.session || !ctx.user) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'You must be logged in to perform this action.',
|
||||
});
|
||||
}
|
||||
|
||||
const isUserAdmin = isAdmin(ctx.user);
|
||||
|
||||
if (!isUserAdmin) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Not authorized to perform this action.',
|
||||
});
|
||||
}
|
||||
|
||||
return await next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
|
||||
user: ctx.user,
|
||||
session: ctx.session,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Routers and Procedures
|
||||
*/
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const authenticatedProcedure = t.procedure.use(authenticatedMiddleware);
|
||||
export const adminProcedure = t.procedure.use(adminMiddleware);
|
||||
|
||||
4
packages/tsconfig/process-env.d.ts
vendored
4
packages/tsconfig/process-env.d.ts
vendored
@ -10,6 +10,7 @@ declare namespace NodeJS {
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
||||
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
@ -20,6 +21,9 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_UPLOAD_BUCKET?: string;
|
||||
NEXT_PRIVATE_UPLOAD_ACCESS_KEY_ID?: string;
|
||||
NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY?: string;
|
||||
NEXT_PRIVATE_UPLOAD_DISTRIBUTION_DOMAIN?: string;
|
||||
NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_ID?: string;
|
||||
NEXT_PRIVATE_UPLOAD_DISTRIBUTION_KEY_CONTENTS?: string;
|
||||
|
||||
NEXT_PRIVATE_SIGNING_TRANSPORT?: 'local' | 'http' | 'gcloud-hsm';
|
||||
NEXT_PRIVATE_SIGNING_PASSPHRASE?: string;
|
||||
|
||||
@ -5,19 +5,20 @@ import { useState } from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentData } from '@documenso/prisma/client';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '../../primitives/dialog';
|
||||
import { LazyPDFViewerNoLoader } from '../../primitives/lazy-pdf-viewer';
|
||||
|
||||
export type DocumentDialogProps = {
|
||||
document: string;
|
||||
documentData: DocumentData;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
/**
|
||||
* A dialog which renders the provided document.
|
||||
*/
|
||||
export default function DocumentDialog({ document, ...props }: DocumentDialogProps) {
|
||||
export default function DocumentDialog({ documentData, ...props }: DocumentDialogProps) {
|
||||
const [documentLoaded, setDocumentLoaded] = useState(false);
|
||||
|
||||
const onDocumentLoad = () => {
|
||||
@ -40,7 +41,7 @@ export default function DocumentDialog({ document, ...props }: DocumentDialogPro
|
||||
>
|
||||
<LazyPDFViewerNoLoader
|
||||
className="mx-auto w-full max-w-3xl xl:max-w-5xl"
|
||||
document={`data:application/pdf;base64,${document}`}
|
||||
documentData={documentData}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDocumentLoad={onDocumentLoad}
|
||||
/>
|
||||
|
||||
150
packages/ui/components/document/document-share-button.tsx
Normal file
150
packages/ui/components/document/document-share-button.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes, useState } from 'react';
|
||||
|
||||
import { Copy, Share } from 'lucide-react';
|
||||
import { FaXTwitter } from 'react-icons/fa6';
|
||||
|
||||
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
|
||||
import {
|
||||
TOAST_DOCUMENT_SHARE_ERROR,
|
||||
TOAST_DOCUMENT_SHARE_SUCCESS,
|
||||
} from '@documenso/lib/constants/toast';
|
||||
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
token: string;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const DocumentShareButton = ({ token, documentId, className }: DocumentShareButtonProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const { copyShareLink, createAndCopyShareLink, isCopyingShareLink } = useCopyShareLink({
|
||||
onSuccess: () => toast(TOAST_DOCUMENT_SHARE_SUCCESS),
|
||||
onError: () => toast(TOAST_DOCUMENT_SHARE_ERROR),
|
||||
});
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const {
|
||||
mutateAsync: createOrGetShareLink,
|
||||
data: shareLink,
|
||||
isLoading,
|
||||
} = trpc.shareLink.createOrGetShareLink.useMutation();
|
||||
|
||||
const onOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
void createOrGetShareLink({
|
||||
token,
|
||||
documentId,
|
||||
});
|
||||
}
|
||||
|
||||
setIsOpen(nextOpen);
|
||||
};
|
||||
|
||||
const onCopyClick = async () => {
|
||||
if (shareLink) {
|
||||
await copyShareLink(`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${shareLink.slug}`);
|
||||
} else {
|
||||
await createAndCopyShareLink({
|
||||
token,
|
||||
documentId,
|
||||
});
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onTweetClick = async () => {
|
||||
let { slug = '' } = shareLink || {};
|
||||
|
||||
if (!slug) {
|
||||
const result = await createOrGetShareLink({
|
||||
token,
|
||||
documentId,
|
||||
});
|
||||
|
||||
slug = result.slug;
|
||||
}
|
||||
|
||||
window.open(
|
||||
generateTwitterIntent(
|
||||
`I just ${token ? 'signed' : 'sent'} a document with @documenso. Check it out!`,
|
||||
`${process.env.NEXT_PUBLIC_WEBAPP_URL}/share/${slug}`,
|
||||
),
|
||||
'_blank',
|
||||
);
|
||||
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!token || !documentId}
|
||||
className={cn('flex-1', className)}
|
||||
loading={isLoading || isCopyingShareLink}
|
||||
>
|
||||
{!isLoading && !isCopyingShareLink && <Share className="mr-2 h-5 w-5" />}
|
||||
Share
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="end">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">Share your signing experience!</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="rounded-md border p-4">
|
||||
I just {token ? 'signed' : 'sent'} a document with{' '}
|
||||
<span className="font-medium text-blue-400">@documenso</span>
|
||||
. Check it out!
|
||||
<span className="mt-2 block" />
|
||||
<span
|
||||
className={cn('break-all font-medium text-blue-400', {
|
||||
'animate-pulse': !shareLink?.slug,
|
||||
})}
|
||||
>
|
||||
{process.env.NEXT_PUBLIC_WEBAPP_URL}/share/{shareLink?.slug || '...'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="mt-4" onClick={onTweetClick}>
|
||||
<FaXTwitter className="mr-2 h-4 w-4" />
|
||||
Tweet
|
||||
</Button>
|
||||
|
||||
<div className="relative flex items-center justify-center gap-x-4 py-4 text-xs uppercase">
|
||||
<div className="bg-border h-px flex-1" />
|
||||
<span className="text-muted-foreground bg-transparent">Or</span>
|
||||
<div className="bg-border h-px flex-1" />
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onClick={onCopyClick}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Link
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -5,23 +5,31 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Image, { StaticImageData } from 'next/image';
|
||||
|
||||
import { animate, motion, useMotionTemplate, useMotionValue, useTransform } from 'framer-motion';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { Signature } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
export type SigningCardProps = {
|
||||
className?: string;
|
||||
name: string;
|
||||
signature?: Signature;
|
||||
signingCelebrationImage?: StaticImageData;
|
||||
};
|
||||
|
||||
/**
|
||||
* 2D signing card.
|
||||
*/
|
||||
export const SigningCard = ({ className, name, signingCelebrationImage }: SigningCardProps) => {
|
||||
export const SigningCard = ({
|
||||
className,
|
||||
name,
|
||||
signature,
|
||||
signingCelebrationImage,
|
||||
}: SigningCardProps) => {
|
||||
return (
|
||||
<div className={cn('relative w-full max-w-xs md:max-w-sm', className)}>
|
||||
<SigningCardContent name={name} />
|
||||
<SigningCardContent name={name} signature={signature} />
|
||||
|
||||
{signingCelebrationImage && (
|
||||
<SigningCardImage signingCelebrationImage={signingCelebrationImage} />
|
||||
@ -33,7 +41,12 @@ export const SigningCard = ({ className, name, signingCelebrationImage }: Signin
|
||||
/**
|
||||
* 3D signing card that follows the mouse movement within a certain range.
|
||||
*/
|
||||
export const SigningCard3D = ({ className, name, signingCelebrationImage }: SigningCardProps) => {
|
||||
export const SigningCard3D = ({
|
||||
className,
|
||||
name,
|
||||
signature,
|
||||
signingCelebrationImage,
|
||||
}: SigningCardProps) => {
|
||||
// Should use % based dimensions by calculating the window height/width.
|
||||
const boundary = 400;
|
||||
|
||||
@ -56,7 +69,7 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
|
||||
const sheenGradient = useMotionTemplate`linear-gradient(
|
||||
30deg,
|
||||
transparent,
|
||||
rgba(var(--sheen-color) / ${trackMouse ? sheenOpacity : 0}) ${sheenPosition}%,
|
||||
rgba(var(--sheen-color) / ${sheenOpacity}) ${sheenPosition}%,
|
||||
transparent)`;
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
@ -98,10 +111,12 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
|
||||
void animate(cardX, 0, { duration: 2, ease: 'backInOut' });
|
||||
void animate(cardY, 0, { duration: 2, ease: 'backInOut' });
|
||||
|
||||
void animate(sheenOpacity, 0, { duration: 2, ease: 'backInOut' });
|
||||
|
||||
setTrackMouse(false);
|
||||
}, 1000);
|
||||
},
|
||||
[cardX, cardY, cardCenterPosition, trackMouse],
|
||||
[cardX, cardY, cardCenterPosition, trackMouse, sheenOpacity],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -126,10 +141,9 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
|
||||
transformStyle: 'preserve-3d',
|
||||
rotateX,
|
||||
rotateY,
|
||||
// willChange: 'transform background-image',
|
||||
}}
|
||||
>
|
||||
<SigningCardContent className="bg-transparent" name={name} />
|
||||
<SigningCardContent className="bg-transparent" name={name} signature={signature} />
|
||||
</motion.div>
|
||||
|
||||
{signingCelebrationImage && (
|
||||
@ -141,10 +155,11 @@ export const SigningCard3D = ({ className, name, signingCelebrationImage }: Sign
|
||||
|
||||
type SigningCardContentProps = {
|
||||
name: string;
|
||||
signature?: Signature;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SigningCardContent = ({ className, name }: SigningCardContentProps) => {
|
||||
const SigningCardContent = ({ className, name, signature }: SigningCardContentProps) => {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
@ -160,14 +175,36 @@ const SigningCardContent = ({ className, name }: SigningCardContentProps) => {
|
||||
container: 'main',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
||||
style={{
|
||||
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{match(signature)
|
||||
.with({ signatureImageAsBase64: P.string }, (signature) => (
|
||||
<img
|
||||
src={signature.signatureImageAsBase64}
|
||||
alt="signature"
|
||||
className="h-full max-w-[100%] dark:invert"
|
||||
/>
|
||||
))
|
||||
.with({ typedSignature: P.string }, (signature) => (
|
||||
<span
|
||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
||||
style={{
|
||||
fontSize: `max(min(4rem, ${(100 / signature.typedSignature.length / 2).toFixed(
|
||||
4,
|
||||
)}cqw), 1.875rem)`,
|
||||
}}
|
||||
>
|
||||
{signature.typedSignature}
|
||||
</span>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<span
|
||||
className="text-muted-foreground/60 group-hover:text-primary/80 break-all font-semibold duration-300"
|
||||
style={{
|
||||
fontSize: `max(min(4rem, ${(100 / name.length / 2).toFixed(4)}cqw), 1.875rem)`,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
"@types/react": "18.2.18",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"react": "18.2.0",
|
||||
"typescript": "^5.1.6"
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/lib": "*",
|
||||
@ -60,13 +60,14 @@
|
||||
"framer-motion": "^10.12.8",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.2",
|
||||
"next": "13.4.19",
|
||||
"next": "14.0.0",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-pdf": "^7.3.3",
|
||||
"react-pdf": "7.3.3",
|
||||
"react-rnd": "^10.4.1",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
"tailwindcss-animate": "^1.0.5"
|
||||
"tailwindcss-animate": "^1.0.5",
|
||||
"ts-pattern": "^5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,12 +11,8 @@ const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AlertDialogPrimitive.AlertDialogPortalProps) => (
|
||||
<AlertDialogPrimitive.Portal className={cn(className)} {...props}>
|
||||
const AlertDialogPortal = ({ children, ...props }: AlertDialogPrimitive.AlertDialogPortalProps) => (
|
||||
<AlertDialogPrimitive.Portal {...props}>
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
82
packages/ui/primitives/combobox.tsx
Normal file
82
packages/ui/primitives/combobox.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Role } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from '@documenso/ui/primitives/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||
|
||||
type ComboboxProps = {
|
||||
listValues: string[];
|
||||
onChange: (_values: string[]) => void;
|
||||
};
|
||||
|
||||
const Combobox = ({ listValues, onChange }: ComboboxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [selectedValues, setSelectedValues] = React.useState<string[]>([]);
|
||||
const dbRoles = Object.values(Role);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedValues(listValues);
|
||||
}, [listValues]);
|
||||
|
||||
const allRoles = [...new Set([...dbRoles, ...selectedValues])];
|
||||
|
||||
const handleSelect = (currentValue: string) => {
|
||||
let newSelectedValues;
|
||||
if (selectedValues.includes(currentValue)) {
|
||||
newSelectedValues = selectedValues.filter((value) => value !== currentValue);
|
||||
} else {
|
||||
newSelectedValues = [...selectedValues, currentValue];
|
||||
}
|
||||
|
||||
setSelectedValues(newSelectedValues);
|
||||
onChange(newSelectedValues);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{selectedValues.length > 0 ? selectedValues.join(', ') : 'Select values...'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={selectedValues.join(', ')} />
|
||||
<CommandEmpty>No value found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allRoles.map((value: string, i: number) => (
|
||||
<CommandItem key={i} onSelect={() => handleSelect(value)}>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedValues.includes(value) ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { Combobox };
|
||||
@ -12,16 +12,16 @@ const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = ({
|
||||
className,
|
||||
children,
|
||||
position = 'start',
|
||||
...props
|
||||
}: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' }) => (
|
||||
<DialogPrimitive.Portal className={cn(className)} {...props}>
|
||||
}: DialogPrimitive.DialogPortalProps & { position?: 'start' | 'end' | 'center' }) => (
|
||||
<DialogPrimitive.Portal {...props}>
|
||||
<div
|
||||
className={cn('fixed inset-0 z-50 flex justify-center sm:items-center', {
|
||||
'items-start': position === 'start',
|
||||
'items-end': position === 'end',
|
||||
'items-center': position === 'center',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
@ -49,7 +49,9 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { position?: 'start' | 'end' }
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
position?: 'start' | 'end' | 'center';
|
||||
}
|
||||
>(({ className, children, position = 'start', ...props }, ref) => (
|
||||
<DialogPortal position={position}>
|
||||
<DialogOverlay />
|
||||
|
||||
@ -74,16 +74,23 @@ const DocumentDropzoneCardCenterVariants: Variants = {
|
||||
|
||||
export type DocumentDropzoneProps = {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onDrop?: (_file: File) => void | Promise<void>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzoneProps) => {
|
||||
export const DocumentDropzone = ({
|
||||
className,
|
||||
onDrop,
|
||||
disabled,
|
||||
...props
|
||||
}: DocumentDropzoneProps) => {
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
multiple: false,
|
||||
disabled,
|
||||
onDrop: ([acceptedFile]) => {
|
||||
if (acceptedFile && onDrop) {
|
||||
void onDrop(acceptedFile);
|
||||
@ -102,11 +109,12 @@ export const DocumentDropzone = ({ className, onDrop, ...props }: DocumentDropzo
|
||||
<Card
|
||||
role="button"
|
||||
className={cn(
|
||||
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
'focus-visible:ring-ring ring-offset-background flex flex-1 cursor-pointer flex-col items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 aria-disabled:pointer-events-none aria-disabled:opacity-60',
|
||||
className,
|
||||
)}
|
||||
gradient={true}
|
||||
degrees={120}
|
||||
aria-disabled={disabled}
|
||||
{...getRootProps()}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@ -246,12 +246,12 @@ export const AddFieldsFormPartial = ({
|
||||
useEffect(() => {
|
||||
if (selectedField) {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('click', onMouseClick);
|
||||
window.addEventListener('mouseup', onMouseClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('click', onMouseClick);
|
||||
window.removeEventListener('mouseup', onMouseClick);
|
||||
};
|
||||
}, [onMouseClick, onMouseMove, selectedField]);
|
||||
|
||||
@ -417,7 +417,7 @@ export const AddFieldsFormPartial = ({
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
|
||||
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
|
||||
>
|
||||
@ -425,7 +425,7 @@ export const AddFieldsFormPartial = ({
|
||||
<CardContent className="flex flex-col items-center justify-center px-6 py-4">
|
||||
<p
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-3xl font-medium',
|
||||
'text-muted-foreground group-data-[selected]:text-foreground w-full truncate text-3xl font-medium',
|
||||
fontCaveat.className,
|
||||
)}
|
||||
>
|
||||
@ -441,7 +441,7 @@ export const AddFieldsFormPartial = ({
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={() => setSelectedField(FieldType.EMAIL)}
|
||||
onMouseDown={() => setSelectedField(FieldType.EMAIL)}
|
||||
data-selected={selectedField === FieldType.EMAIL ? true : undefined}
|
||||
>
|
||||
@ -464,7 +464,7 @@ export const AddFieldsFormPartial = ({
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={() => setSelectedField(FieldType.NAME)}
|
||||
onMouseDown={() => setSelectedField(FieldType.NAME)}
|
||||
data-selected={selectedField === FieldType.NAME ? true : undefined}
|
||||
>
|
||||
@ -487,7 +487,7 @@ export const AddFieldsFormPartial = ({
|
||||
type="button"
|
||||
className="group h-full w-full"
|
||||
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={() => setSelectedField(FieldType.DATE)}
|
||||
onMouseDown={() => setSelectedField(FieldType.DATE)}
|
||||
data-selected={selectedField === FieldType.DATE ? true : undefined}
|
||||
>
|
||||
|
||||
@ -7,6 +7,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Plus, Trash } from 'lucide-react';
|
||||
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { Field, Recipient, SendStatus } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@ -40,6 +41,7 @@ export const AddSignersFormPartial = ({
|
||||
onSubmit,
|
||||
}: AddSignersFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const { remaining } = useLimits();
|
||||
|
||||
const initialId = useId();
|
||||
|
||||
@ -202,7 +204,11 @@ export const AddSignersFormPartial = ({
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button type="button" disabled={isSubmitting} onClick={() => onAddSigner()}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSubmitting || signers.length >= remaining.recipients}
|
||||
onClick={() => onAddSigner()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Signer
|
||||
</Button>
|
||||
|
||||
@ -97,10 +97,7 @@ export const DocumentFlowFormContainerStep = ({
|
||||
return (
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{title}{' '}
|
||||
<span>
|
||||
({step}/{maxStep})
|
||||
</span>
|
||||
Step <span>{`${step} of ${maxStep}`}</span>
|
||||
</p>
|
||||
|
||||
<div className="bg-muted relative mt-4 h-[2px] rounded-md">
|
||||
|
||||
@ -70,25 +70,23 @@ export function SinglePlayerModeSignatureField({
|
||||
throw new Error('Invalid field type');
|
||||
}
|
||||
|
||||
const $paragraphEl = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
const { height, width } = useFieldPageCoords(field);
|
||||
|
||||
const insertedBase64Signature = field.inserted && field.Signature?.signatureImageAsBase64;
|
||||
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
|
||||
|
||||
const scalingFactor = useElementScaleSize(
|
||||
{
|
||||
height,
|
||||
width,
|
||||
},
|
||||
$paragraphEl,
|
||||
insertedTypeSignature || '',
|
||||
maxFontSize,
|
||||
fontVariableValue,
|
||||
);
|
||||
|
||||
const fontSize = maxFontSize * scalingFactor;
|
||||
|
||||
const insertedBase64Signature = field.inserted && field.Signature?.signatureImageAsBase64;
|
||||
const insertedTypeSignature = field.inserted && field.Signature?.typedSignature;
|
||||
|
||||
return (
|
||||
<SinglePlayerModeFieldCardContainer field={field}>
|
||||
{insertedBase64Signature ? (
|
||||
@ -99,7 +97,6 @@ export function SinglePlayerModeSignatureField({
|
||||
/>
|
||||
) : insertedTypeSignature ? (
|
||||
<p
|
||||
ref={$paragraphEl}
|
||||
style={{
|
||||
fontSize: `clamp(${minFontSize}px, ${fontSize}px, ${maxFontSize}px)`,
|
||||
fontFamily: `var(${fontVariable})`,
|
||||
@ -145,7 +142,7 @@ export function SinglePlayerModeCustomTextField({
|
||||
height,
|
||||
width,
|
||||
},
|
||||
$paragraphEl,
|
||||
field.customText,
|
||||
maxFontSize,
|
||||
fontVariableValue,
|
||||
);
|
||||
|
||||
@ -85,7 +85,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Loader } from 'lucide-react';
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
@ -9,8 +9,12 @@ import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/esm/Page/TextLayer.css';
|
||||
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
||||
import { DocumentData } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
import { useToast } from './use-toast';
|
||||
|
||||
export type LoadedPDFDocument = PDFDocumentProxy;
|
||||
|
||||
/**
|
||||
@ -28,9 +32,17 @@ export type OnPDFViewerPageClick = (_event: {
|
||||
pageY: number;
|
||||
}) => void | Promise<void>;
|
||||
|
||||
const PDFLoader = () => (
|
||||
<>
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</>
|
||||
);
|
||||
|
||||
export type PDFViewerProps = {
|
||||
className?: string;
|
||||
document: string;
|
||||
documentData: DocumentData;
|
||||
onDocumentLoad?: (_doc: LoadedPDFDocument) => void;
|
||||
onPageClick?: OnPDFViewerPageClick;
|
||||
[key: string]: unknown;
|
||||
@ -38,17 +50,29 @@ export type PDFViewerProps = {
|
||||
|
||||
export const PDFViewer = ({
|
||||
className,
|
||||
document,
|
||||
documentData,
|
||||
onDocumentLoad,
|
||||
onPageClick,
|
||||
...props
|
||||
}: PDFViewerProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false);
|
||||
const [documentBytes, setDocumentBytes] = useState<Uint8Array | null>(null);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
|
||||
const memoizedData = useMemo(
|
||||
() => ({ type: documentData.type, data: documentData.data }),
|
||||
[documentData.data, documentData.type],
|
||||
);
|
||||
|
||||
const isLoading = isDocumentBytesLoading || !documentBytes;
|
||||
|
||||
const onDocumentLoaded = (doc: LoadedPDFDocument) => {
|
||||
setNumPages(doc.numPages);
|
||||
onDocumentLoad?.(doc);
|
||||
@ -110,63 +134,93 @@ export const PDFViewer = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDocumentBytes = async () => {
|
||||
try {
|
||||
setIsDocumentBytesLoading(true);
|
||||
|
||||
const bytes = await getFile(memoizedData);
|
||||
|
||||
setDocumentBytes(bytes);
|
||||
|
||||
setIsDocumentBytesLoading(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'An error occurred while loading the document.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void fetchDocumentBytes();
|
||||
}, [memoizedData, toast]);
|
||||
|
||||
return (
|
||||
<div ref={$el} className={cn('overflow-hidden', className)} {...props}>
|
||||
<PDFDocument
|
||||
file={document}
|
||||
className={cn('w-full overflow-hidden rounded', {
|
||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||
})}
|
||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
|
||||
// Therefore we add some additional custom error handling.
|
||||
onSourceError={() => {
|
||||
setPdfError(true);
|
||||
}}
|
||||
externalLinkTarget="_blank"
|
||||
loading={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
{pdfError ? (
|
||||
{isLoading ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[80vh] max-h-[60rem] w-full flex-col items-center justify-center overflow-hidden rounded',
|
||||
)}
|
||||
>
|
||||
<PDFLoader />
|
||||
</div>
|
||||
) : (
|
||||
<PDFDocument
|
||||
file={documentBytes.buffer}
|
||||
className={cn('w-full overflow-hidden rounded', {
|
||||
'h-[80vh] max-h-[60rem]': numPages === 0,
|
||||
})}
|
||||
onLoadSuccess={(d) => onDocumentLoaded(d)}
|
||||
// Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop.
|
||||
// Therefore we add some additional custom error handling.
|
||||
onSourceError={() => {
|
||||
setPdfError(true);
|
||||
}}
|
||||
externalLinkTarget="_blank"
|
||||
loading={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
{pdfError ? (
|
||||
<div className="text-muted-foreground text-center">
|
||||
<p>Something went wrong while loading the document.</p>
|
||||
<p className="mt-1 text-sm">Please try again or contact our support.</p>
|
||||
</div>
|
||||
) : (
|
||||
<PDFLoader />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
error={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
<div className="text-muted-foreground text-center">
|
||||
<p>Something went wrong while loading the document.</p>
|
||||
<p className="mt-1 text-sm">Please try again or contact our support.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Loader className="text-documenso h-12 w-12 animate-spin" />
|
||||
|
||||
<p className="text-muted-foreground mt-4">Loading document...</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
error={
|
||||
<div className="dark:bg-background flex h-[80vh] max-h-[60rem] flex-col items-center justify-center bg-white/50">
|
||||
<div className="text-muted-foreground text-center">
|
||||
<p>Something went wrong while loading the document.</p>
|
||||
<p className="mt-1 text-sm">Please try again or contact our support.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border my-8 overflow-hidden rounded border first:mt-0 last:mb-0"
|
||||
>
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</PDFDocument>
|
||||
}
|
||||
>
|
||||
{Array(numPages)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border my-8 overflow-hidden rounded border first:mt-0 last:mb-0"
|
||||
>
|
||||
<PDFPage
|
||||
pageNumber={i + 1}
|
||||
width={width}
|
||||
renderAnnotationLayer={false}
|
||||
renderTextLayer={false}
|
||||
loading={() => ''}
|
||||
onClick={(e) => onDocumentPageClick(e, i + 1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</PDFDocument>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -28,8 +28,8 @@ interface SheetPortalProps
|
||||
extends SheetPrimitive.DialogPortalProps,
|
||||
VariantProps<typeof portalVariants> {}
|
||||
|
||||
const SheetPortal = ({ position, className, children, ...props }: SheetPortalProps) => (
|
||||
<SheetPrimitive.Portal className={cn(className)} {...props}>
|
||||
const SheetPortal = ({ position, children, ...props }: SheetPortalProps) => (
|
||||
<SheetPrimitive.Portal {...props}>
|
||||
<div className={portalVariants({ position })}>{children}</div>
|
||||
</SheetPrimitive.Portal>
|
||||
);
|
||||
|
||||
@ -22,10 +22,12 @@ const DPI = 2;
|
||||
|
||||
export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChange'> & {
|
||||
onChange?: (_signatureDataUrl: string | null) => void;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export const SignaturePad = ({
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
onChange,
|
||||
...props
|
||||
@ -210,7 +212,7 @@ export const SignaturePad = ({
|
||||
}, [defaultValue]);
|
||||
|
||||
return (
|
||||
<div className="relative block">
|
||||
<div className={cn('relative block', containerClassName)}>
|
||||
<canvas
|
||||
ref={$el}
|
||||
className={cn('relative block dark:invert', className)}
|
||||
@ -226,7 +228,7 @@ export const SignaturePad = ({
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<button
|
||||
type="button"
|
||||
className="focus-visible:ring-ring ring-offset-background rounded-full p-0 text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2"
|
||||
className="focus-visible:ring-ring ring-offset-background text-muted-foreground rounded-full p-0 text-xs focus-visible:outline-none focus-visible:ring-2"
|
||||
onClick={() => onClearClick()}
|
||||
>
|
||||
Clear Signature
|
||||
|
||||
@ -133,7 +133,7 @@ function dispatch(action: Action) {
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
export type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
Reference in New Issue
Block a user