Merge branch 'feat/refresh' into date-format-setting

This commit is contained in:
Catalin Pit
2023-11-01 14:45:10 +02:00
191 changed files with 8266 additions and 4873 deletions

View 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,
};
}

View 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];
}

View File

@ -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;
}

View 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,
};

View File

@ -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,
};
},

View File

@ -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);

View File

@ -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",

View 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),
};
};

View 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,
},
});
};

View File

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

View 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,
},
});
};

View 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 } });
};

View File

@ -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 });
}
};

View File

@ -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),
},
],
});
}),
]);

View 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,
});
};

View 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;
};

View File

@ -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,
},
},
});
};

View File

@ -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 ?? '', {

View File

@ -0,0 +1,7 @@
declare module 'stripe' {
namespace Stripe {
interface Product {
features?: Array<{ name: string }>;
}
}
}

View File

@ -7,7 +7,7 @@ export type GetSubscriptionByUserIdOptions = {
};
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
return prisma.subscription.findFirst({
return await prisma.subscription.findFirst({
where: {
userId,
},

View 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,
},
});
};

View 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),
};
};

View File

@ -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,
},
});

View File

@ -19,5 +19,6 @@ declare module 'next-auth/jwt' {
id: string | number;
name?: string | null;
email: string | null;
lastSignedIn?: string | null;
}
}

View File

@ -0,0 +1,3 @@
export const toHumanPrice = (price: number) => {
return Number(price / 100).toFixed(2);
};

View File

@ -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, {

View File

@ -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();

View File

@ -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,
});

View File

@ -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, {