feat: team api tokens

This commit is contained in:
Mythie
2024-02-22 13:39:34 +11:00
parent 22e3a79a72
commit 2abcdd7533
36 changed files with 903 additions and 214 deletions

View File

@@ -61,7 +61,7 @@ export const DeleteDocumentDialog = ({
const onDelete = async () => { const onDelete = async () => {
try { try {
await deleteDocument({ id, status }); await deleteDocument({ id });
} catch { } catch {
toast({ toast({
title: 'Something went wrong', title: 'Something went wrong',

View File

@@ -0,0 +1,85 @@
import { DateTime } from 'luxon';
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getTeamTokens } from '@documenso/lib/server-only/public-api/get-all-team-tokens';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { Button } from '@documenso/ui/primitives/button';
import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-token-dialog';
import { LocaleDate } from '~/components/formatter/locale-date';
import { ApiTokenForm } from '~/components/forms/token';
type ApiTokensPageProps = {
params: {
teamUrl: string;
};
};
export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const { teamUrl } = params;
const { user } = await getRequiredServerComponentSession();
const team = await getTeamByUrl({ userId: user.id, teamUrl });
const tokens = await getTeamTokens({ userId: user.id, teamId: team.id });
return (
<div>
<h3 className="text-2xl font-semibold">API Tokens</h3>
<p className="text-muted-foreground mt-2 text-sm">
On this page, you can create new API tokens and manage the existing ones.
</p>
<hr className="my-4" />
<ApiTokenForm className="max-w-xl" teamId={team.id} />
<hr className="mb-4 mt-8" />
<h4 className="text-xl font-medium">Your existing tokens</h4>
{tokens.length === 0 && (
<div className="mb-4">
<p className="text-muted-foreground mt-2 text-sm italic">
Your tokens will be shown here once you create them.
</p>
</div>
)}
{tokens.length > 0 && (
<div className="mt-4 flex max-w-xl flex-col gap-y-4">
{tokens.map((token) => (
<div key={token.id} className="border-border rounded-lg border p-4">
<div className="flex items-center justify-between gap-x-4">
<div>
<h5 className="text-base">{token.name}</h5>
<p className="text-muted-foreground mt-2 text-xs">
Created on <LocaleDate date={token.createdAt} format={DateTime.DATETIME_FULL} />
</p>
{token.expires ? (
<p className="text-muted-foreground mt-1 text-xs">
Expires on <LocaleDate date={token.expires} format={DateTime.DATETIME_FULL} />
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Token doesn't have an expiration date
</p>
)}
</div>
<div>
<DeleteTokenDialog token={token} teamId={team.id}>
<Button variant="destructive">Delete</Button>
</DeleteTokenDialog>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -32,12 +32,18 @@ import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export type DeleteTokenDialogProps = { export type DeleteTokenDialogProps = {
teamId?: number;
token: Pick<ApiToken, 'id' | 'name'>; token: Pick<ApiToken, 'id' | 'name'>;
onDelete?: () => void; onDelete?: () => void;
children?: React.ReactNode; children?: React.ReactNode;
}; };
export default function DeleteTokenDialog({ token, onDelete, children }: DeleteTokenDialogProps) { export default function DeleteTokenDialog({
teamId,
token,
onDelete,
children,
}: DeleteTokenDialogProps) {
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
@@ -70,6 +76,7 @@ export default function DeleteTokenDialog({ token, onDelete, children }: DeleteT
try { try {
await deleteTokenMutation({ await deleteTokenMutation({
id: token.id, id: token.id,
teamId,
}); });
toast({ toast({

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation'; import { useParams, usePathname } from 'next/navigation';
import { CreditCard, Settings, Users } from 'lucide-react'; import { Braces, CreditCard, Settings, Users } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -21,6 +21,7 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`; const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`; const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const billingPath = `/t/${teamUrl}/settings/billing`; const billingPath = `/t/${teamUrl}/settings/billing`;
return ( return (
@@ -48,6 +49,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
</Button> </Button>
</Link> </Link>
<Link href={tokensPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
>
<Braces className="mr-2 h-5 w-5" />
API Tokens
</Button>
</Link>
{IS_BILLING_ENABLED() && ( {IS_BILLING_ENABLED() && (
<Link href={billingPath}> <Link href={billingPath}>
<Button <Button

View File

@@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams, usePathname } from 'next/navigation'; import { useParams, usePathname } from 'next/navigation';
import { CreditCard, Key, User } from 'lucide-react'; import { Braces, CreditCard, Key, User } from 'lucide-react';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@@ -21,6 +21,7 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
const settingsPath = `/t/${teamUrl}/settings`; const settingsPath = `/t/${teamUrl}/settings`;
const membersPath = `/t/${teamUrl}/settings/members`; const membersPath = `/t/${teamUrl}/settings/members`;
const tokensPath = `/t/${teamUrl}/settings/tokens`;
const billingPath = `/t/${teamUrl}/settings/billing`; const billingPath = `/t/${teamUrl}/settings/billing`;
return ( return (
@@ -56,6 +57,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
</Button> </Button>
</Link> </Link>
<Link href={tokensPath}>
<Button
variant="ghost"
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
>
<Braces className="mr-2 h-5 w-5" />
API Tokens
</Button>
</Link>
{IS_BILLING_ENABLED() && ( {IS_BILLING_ENABLED() && (
<Link href={billingPath}> <Link href={billingPath}>
<Button <Button

View File

@@ -46,9 +46,10 @@ type TCreateTokenFormSchema = z.infer<typeof ZCreateTokenFormSchema>;
export type ApiTokenFormProps = { export type ApiTokenFormProps = {
className?: string; className?: string;
teamId?: number;
}; };
export const ApiTokenForm = ({ className }: ApiTokenFormProps) => { export const ApiTokenForm = ({ className, teamId }: ApiTokenFormProps) => {
const router = useRouter(); const router = useRouter();
const [, copy] = useCopyToClipboard(); const [, copy] = useCopyToClipboard();
@@ -96,6 +97,7 @@ export const ApiTokenForm = ({ className }: ApiTokenFormProps) => {
const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => { const onSubmit = async ({ tokenName, expirationDate }: TCreateTokenMutationSchema) => {
try { try {
await createTokenMutation({ await createTokenMutation({
teamId,
tokenName, tokenName,
expirationDate: noExpirationDate ? null : expirationDate, expirationDate: noExpirationDate ? null : expirationDate,
}); });

View File

@@ -47,6 +47,9 @@
"apps/*", "apps/*",
"packages/*" "packages/*"
], ],
"dependencies": {
"next-runtime-env": "^3.2.0"
},
"overrides": { "overrides": {
"next-auth": { "next-auth": {
"next": "14.0.3" "next": "14.0.3"

View File

@@ -1,7 +1,6 @@
import { initContract } from '@ts-rest/core'; import { initContract } from '@ts-rest/core';
import { import {
ZSendDocumentForSigningMutationSchema as SendDocumentMutationSchema,
ZAuthorizationHeadersSchema, ZAuthorizationHeadersSchema,
ZCreateDocumentFromTemplateMutationResponseSchema, ZCreateDocumentFromTemplateMutationResponseSchema,
ZCreateDocumentFromTemplateMutationSchema, ZCreateDocumentFromTemplateMutationSchema,
@@ -13,6 +12,7 @@ import {
ZDeleteFieldMutationSchema, ZDeleteFieldMutationSchema,
ZDeleteRecipientMutationSchema, ZDeleteRecipientMutationSchema,
ZGetDocumentsQuerySchema, ZGetDocumentsQuerySchema,
ZSendDocumentForSigningMutationSchema,
ZSuccessfulDocumentResponseSchema, ZSuccessfulDocumentResponseSchema,
ZSuccessfulFieldResponseSchema, ZSuccessfulFieldResponseSchema,
ZSuccessfulGetDocumentResponseSchema, ZSuccessfulGetDocumentResponseSchema,
@@ -72,13 +72,13 @@ export const ApiContractV1 = c.router(
401: ZUnsuccessfulResponseSchema, 401: ZUnsuccessfulResponseSchema,
404: ZUnsuccessfulResponseSchema, 404: ZUnsuccessfulResponseSchema,
}, },
summary: 'Upload a new document and get a presigned URL', summary: 'Create a new document from an existing template',
}, },
sendDocument: { sendDocument: {
method: 'POST', method: 'POST',
path: '/api/v1/documents/:id/send', path: '/api/v1/documents/:id/send',
body: SendDocumentMutationSchema, body: ZSendDocumentForSigningMutationSchema,
responses: { responses: {
200: ZSuccessfulSigningResponseSchema, 200: ZSuccessfulSigningResponseSchema,
400: ZUnsuccessfulResponseSchema, 400: ZUnsuccessfulResponseSchema,

View File

@@ -18,6 +18,7 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient'; import { updateRecipient } from '@documenso/lib/server-only/recipient/update-recipient';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template'; import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions'; import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentDataType, DocumentStatus, SigningStatus } from '@documenso/prisma/client';
@@ -25,11 +26,16 @@ import { ApiContractV1 } from './contract';
import { authenticatedMiddleware } from './middleware/authenticated'; import { authenticatedMiddleware } from './middleware/authenticated';
export const ApiContractV1Implementation = createNextRoute(ApiContractV1, { export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
getDocuments: authenticatedMiddleware(async (args, user) => { getDocuments: authenticatedMiddleware(async (args, user, team) => {
const page = Number(args.query.page) || 1; const page = Number(args.query.page) || 1;
const perPage = Number(args.query.perPage) || 10; const perPage = Number(args.query.perPage) || 10;
const { data: documents, totalPages } = await findDocuments({ page, perPage, userId: user.id }); const { data: documents, totalPages } = await findDocuments({
page,
perPage,
userId: user.id,
teamId: team?.id,
});
return { return {
status: 200, status: 200,
@@ -40,13 +46,19 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
}), }),
getDocument: authenticatedMiddleware(async (args, user) => { getDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params; const { id: documentId } = args.params;
try { try {
const document = await getDocumentById({ id: Number(documentId), userId: user.id }); const document = await getDocumentById({
id: Number(documentId),
userId: user.id,
teamId: team?.id,
});
const recipients = await getRecipientsForDocument({ const recipients = await getRecipientsForDocument({
documentId: Number(documentId), documentId: Number(documentId),
teamId: team?.id,
userId: user.id, userId: user.id,
}); });
@@ -67,16 +79,29 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
deleteDocument: authenticatedMiddleware(async (args, user) => { deleteDocument: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params; const { id: documentId } = args.params;
try { try {
const document = await getDocumentById({ id: Number(documentId), userId: user.id }); const document = await getDocumentById({
const deletedDocument = await deleteDocument({
id: Number(documentId), id: Number(documentId),
userId: user.id, userId: user.id,
status: document.status, teamId: team?.id,
});
if (!document) {
return {
status: 404,
body: {
message: 'Document not found',
},
};
}
const deletedDocument = await deleteDocument({
id: document.id,
userId: user.id,
teamId: team?.id,
}); });
return { return {
@@ -93,7 +118,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
createDocument: authenticatedMiddleware(async (args, user) => { createDocument: authenticatedMiddleware(async (args, user, team) => {
const { body } = args; const { body } = args;
try { try {
@@ -118,13 +143,17 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const document = await createDocument({ const document = await createDocument({
title: body.title, title: body.title,
userId: user.id, userId: user.id,
teamId: team?.id,
documentDataId: documentData.id, documentDataId: documentData.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
const recipients = await setRecipientsForDocument({ const recipients = await setRecipientsForDocument({
userId: user.id, userId: user.id,
teamId: team?.id,
documentId: document.id, documentId: document.id,
recipients: body.recipients, recipients: body.recipients,
requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
return { return {
@@ -151,7 +180,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
createDocumentFromTemplate: authenticatedMiddleware(async (args, user) => { createDocumentFromTemplate: authenticatedMiddleware(async (args, user, team) => {
const { body, params } = args; const { body, params } = args;
const templateId = Number(params.templateId); const templateId = Number(params.templateId);
@@ -161,14 +190,16 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const document = await createDocumentFromTemplate({ const document = await createDocumentFromTemplate({
templateId, templateId,
userId: user.id, userId: user.id,
teamId: team?.id,
recipients: body.recipients, recipients: body.recipients,
}); });
await updateDocument({ await updateDocument({
documentId: document.id, documentId: document.id,
userId: user.id, userId: user.id,
teamId: team?.id,
data: { data: {
title: body.title, title: fileName,
}, },
}); });
@@ -180,6 +211,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
message: body.meta.message, message: body.meta.message,
dateFormat: body.meta.dateFormat, dateFormat: body.meta.dateFormat,
timezone: body.meta.timezone, timezone: body.meta.timezone,
requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
} }
@@ -198,10 +230,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
}), }),
sendDocument: authenticatedMiddleware(async (args, user) => { sendDocument: authenticatedMiddleware(async (args, user, team) => {
const { id } = args.params; const { id } = args.params;
const document = await getDocumentById({ id: Number(id), userId: user.id }); const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
if (!document) { if (!document) {
return { return {
@@ -212,11 +244,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
} }
if (document.status === 'PENDING') { if (document.status === DocumentStatus.COMPLETED) {
return { return {
status: 400, status: 400,
body: { body: {
message: 'Document is already waiting for signing', message: 'Document is already complete',
}, },
}; };
} }
@@ -258,6 +290,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
await sendDocument({ await sendDocument({
documentId: Number(id), documentId: Number(id),
userId: user.id, userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
return { return {
@@ -276,13 +310,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
createRecipient: authenticatedMiddleware(async (args, user) => { createRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params; const { id: documentId } = args.params;
const { name, email, role } = args.body; const { name, email, role } = args.body;
const document = await getDocumentById({ const document = await getDocumentById({
id: Number(documentId), id: Number(documentId),
userId: user.id, userId: user.id,
teamId: team?.id,
}); });
if (!document) { if (!document) {
@@ -306,6 +341,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const recipients = await getRecipientsForDocument({ const recipients = await getRecipientsForDocument({
documentId: Number(documentId), documentId: Number(documentId),
userId: user.id, userId: user.id,
teamId: team?.id,
}); });
const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email); const recipientAlreadyExists = recipients.some((recipient) => recipient.email === email);
@@ -323,6 +359,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const newRecipients = await setRecipientsForDocument({ const newRecipients = await setRecipientsForDocument({
documentId: Number(documentId), documentId: Number(documentId),
userId: user.id, userId: user.id,
teamId: team?.id,
recipients: [ recipients: [
...recipients, ...recipients,
{ {
@@ -331,6 +368,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
role, role,
}, },
], ],
requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
const newRecipient = newRecipients.find((recipient) => recipient.email === email); const newRecipient = newRecipients.find((recipient) => recipient.email === email);
@@ -356,13 +394,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
} }
}), }),
updateRecipient: authenticatedMiddleware(async (args, user) => { updateRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, recipientId } = args.params; const { id: documentId, recipientId } = args.params;
const { name, email, role } = args.body; const { name, email, role } = args.body;
const document = await getDocumentById({ const document = await getDocumentById({
id: Number(documentId), id: Number(documentId),
userId: user.id, userId: user.id,
teamId: team?.id,
}); });
if (!document) { if (!document) {
@@ -386,9 +425,12 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const updatedRecipient = await updateRecipient({ const updatedRecipient = await updateRecipient({
documentId: Number(documentId), documentId: Number(documentId),
recipientId: Number(recipientId), recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
email, email,
name, name,
role, role,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null); }).catch(() => null);
if (!updatedRecipient) { if (!updatedRecipient) {
@@ -409,12 +451,13 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
}), }),
deleteRecipient: authenticatedMiddleware(async (args, user) => { deleteRecipient: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, recipientId } = args.params; const { id: documentId, recipientId } = args.params;
const document = await getDocumentById({ const document = await getDocumentById({
id: Number(documentId), id: Number(documentId),
userId: user.id, userId: user.id,
teamId: team?.id,
}); });
if (!document) { if (!document) {
@@ -438,6 +481,9 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const deletedRecipient = await deleteRecipient({ const deletedRecipient = await deleteRecipient({
documentId: Number(documentId), documentId: Number(documentId),
recipientId: Number(recipientId), recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null); }).catch(() => null);
if (!deletedRecipient) { if (!deletedRecipient) {
@@ -458,13 +504,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
}), }),
createField: authenticatedMiddleware(async (args, user) => { createField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId } = args.params; const { id: documentId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body; const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
const document = await getDocumentById({ const document = await getDocumentById({
id: Number(documentId), id: Number(documentId),
userId: user.id, userId: user.id,
teamId: team?.id,
}); });
if (!document) { if (!document) {
@@ -511,12 +558,15 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const field = await createField({ const field = await createField({
documentId: Number(documentId), documentId: Number(documentId),
recipientId: Number(recipientId), recipientId: Number(recipientId),
userId: user.id,
teamId: team?.id,
type, type,
pageNumber, pageNumber,
pageX, pageX,
pageY, pageY,
pageWidth, pageWidth,
pageHeight, pageHeight,
requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
const remappedField = { const remappedField = {
@@ -542,13 +592,14 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
}), }),
updateField: authenticatedMiddleware(async (args, user) => { updateField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, fieldId } = args.params; const { id: documentId, fieldId } = args.params;
const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body; const { recipientId, type, pageNumber, pageWidth, pageHeight, pageX, pageY } = args.body;
const document = await getDocumentById({ const document = await getDocumentById({
id: Number(documentId), id: Number(documentId),
userId: user.id, userId: user.id,
teamId: team?.id,
}); });
if (!document) { if (!document) {
@@ -594,6 +645,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const updatedField = await updateField({ const updatedField = await updateField({
fieldId: Number(fieldId), fieldId: Number(fieldId),
userId: user.id,
teamId: team?.id,
documentId: Number(documentId), documentId: Number(documentId),
recipientId: recipientId ? Number(recipientId) : undefined, recipientId: recipientId ? Number(recipientId) : undefined,
type, type,
@@ -602,6 +655,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
pageY, pageY,
pageWidth, pageWidth,
pageHeight, pageHeight,
requestMetadata: extractNextApiRequestMetadata(args.req),
}); });
const remappedField = { const remappedField = {
@@ -627,7 +681,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
}; };
}), }),
deleteField: authenticatedMiddleware(async (args, user) => { deleteField: authenticatedMiddleware(async (args, user, team) => {
const { id: documentId, fieldId } = args.params; const { id: documentId, fieldId } = args.params;
const document = await getDocumentById({ const document = await getDocumentById({
@@ -684,6 +738,9 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
const deletedField = await deleteField({ const deletedField = await deleteField({
documentId: Number(documentId), documentId: Number(documentId),
fieldId: Number(fieldId), fieldId: Number(fieldId),
userId: user.id,
teamId: team?.id,
requestMetadata: extractNextApiRequestMetadata(args.req),
}).catch(() => null); }).catch(() => null);
if (!deletedField) { if (!deletedField) {

View File

@@ -1,7 +1,7 @@
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
import { getUserByApiToken } from '@documenso/lib/server-only/public-api/get-user-by-token'; import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import type { User } from '@documenso/prisma/client'; import type { Team, User } from '@documenso/prisma/client';
export const authenticatedMiddleware = < export const authenticatedMiddleware = <
T extends { T extends {
@@ -12,7 +12,7 @@ export const authenticatedMiddleware = <
body: unknown; body: unknown;
}, },
>( >(
handler: (args: T, user: User) => Promise<R>, handler: (args: T, user: User, team?: Team | null) => Promise<R>,
) => { ) => {
return async (args: T) => { return async (args: T) => {
try { try {
@@ -25,9 +25,9 @@ export const authenticatedMiddleware = <
throw new Error('Token was not provided for authenticated middleware'); throw new Error('Token was not provided for authenticated middleware');
} }
const user = await getUserByApiToken({ token }); const apiToken = await getApiTokenByToken({ token });
return await handler(args, user); return await handler(args, apiToken.user, apiToken.team);
} catch (_err) { } catch (_err) {
console.log({ _err }); console.log({ _err });
return { return {

View File

@@ -25,6 +25,7 @@ export type TDeleteDocumentMutationSchema = typeof ZDeleteDocumentMutationSchema
export const ZSuccessfulDocumentResponseSchema = z.object({ export const ZSuccessfulDocumentResponseSchema = z.object({
id: z.number(), id: z.number(),
userId: z.number(), userId: z.number(),
teamId: z.number().nullish(),
title: z.string(), title: z.string(),
status: z.string(), status: z.string(),
documentDataId: z.string(), documentDataId: z.string(),

View File

@@ -1,5 +1,5 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { Prisma } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client';
export interface FindDocumentsOptions { export interface FindDocumentsOptions {
term?: string; term?: string;

View File

@@ -14,39 +14,45 @@ import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
export type DeleteDocumentOptions = { export type DeleteDocumentOptions = {
id: number; id: number;
userId: number; userId: number;
status: DocumentStatus; teamId?: number;
}; };
export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptions) => { export const deleteDocument = async ({ id, userId, teamId }: DeleteDocumentOptions) => {
// if the document is a draft, hard-delete // if the document is a draft, hard-delete
if (status === DocumentStatus.DRAFT) { const document = await prisma.document.findUnique({
return await prisma.document.delete({ where: { id, userId, status: DocumentStatus.DRAFT } }); where: {
id,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
include: {
Recipient: true,
documentMeta: true,
User: true,
},
});
if (!document) {
throw new Error('Document not found');
} }
const { status, User: user } = document;
// if the document is pending, send cancellation emails to all recipients // if the document is pending, send cancellation emails to all recipients
if (status === DocumentStatus.PENDING) { if (status === DocumentStatus.PENDING) {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
const document = await prisma.document.findUnique({
where: {
id,
status,
userId,
},
include: {
Recipient: true,
documentMeta: true,
},
});
if (!document) {
throw new Error('Document not found');
}
if (document.Recipient.length > 0) { if (document.Recipient.length > 0) {
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
@@ -81,6 +87,7 @@ export const deleteDocument = async ({ id, userId, status }: DeleteDocumentOptio
return await prisma.document.update({ return await prisma.document.update({
where: { where: {
id, id,
teamId,
}, },
data: { data: {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),

View File

@@ -16,9 +16,8 @@ import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client';
import { getDocumentWhereInput } from './get-document-by-id';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getDocumentWhereInput } from './get-document-by-id';
export type ResendDocumentOptions = { export type ResendDocumentOptions = {
documentId: number; documentId: number;

View File

@@ -20,12 +20,14 @@ import {
export type SendDocumentOptions = { export type SendDocumentOptions = {
documentId: number; documentId: number;
userId: number; userId: number;
teamId?: number;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
}; };
export const sendDocument = async ({ export const sendDocument = async ({
documentId, documentId,
userId, userId,
teamId,
requestMetadata, requestMetadata,
}: SendDocumentOptions) => { }: SendDocumentOptions) => {
const user = await prisma.user.findFirstOrThrow({ const user = await prisma.user.findFirstOrThrow({
@@ -42,20 +44,21 @@ export const sendDocument = async ({
const document = await prisma.document.findUnique({ const document = await prisma.document.findUnique({
where: { where: {
id: documentId, id: documentId,
OR: [ ...(teamId
{ ? {
userId, team: {
}, id: teamId,
{ members: {
team: { some: {
members: { userId,
some: { },
userId,
}, },
}, },
}, }
}, : {
], userId,
teamId: null,
}),
}, },
include: { include: {
Recipient: true, Recipient: true,

View File

@@ -5,16 +5,36 @@ import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
export type UpdateDocumentOptions = { export type UpdateDocumentOptions = {
documentId: number;
data: Prisma.DocumentUpdateInput; data: Prisma.DocumentUpdateInput;
userId: number; userId: number;
documentId: number; teamId?: number;
}; };
export const updateDocument = async ({ documentId, userId, data }: UpdateDocumentOptions) => { export const updateDocument = async ({
documentId,
userId,
teamId,
data,
}: UpdateDocumentOptions) => {
return await prisma.document.update({ return await prisma.document.update({
where: { where: {
id: documentId, id: documentId,
userId, ...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
}, },
data: { data: {
...data, ...data,

View File

@@ -7,6 +7,7 @@ import { prisma } from '@documenso/prisma';
export type UpdateTitleOptions = { export type UpdateTitleOptions = {
userId: number; userId: number;
teamId?: number;
documentId: number; documentId: number;
title: string; title: string;
requestMetadata?: RequestMetadata; requestMetadata?: RequestMetadata;
@@ -14,6 +15,7 @@ export type UpdateTitleOptions = {
export const updateTitle = async ({ export const updateTitle = async ({
userId, userId,
teamId,
documentId, documentId,
title, title,
requestMetadata, requestMetadata,
@@ -28,20 +30,21 @@ export const updateTitle = async ({
const document = await tx.document.findFirstOrThrow({ const document = await tx.document.findFirstOrThrow({
where: { where: {
id: documentId, id: documentId,
OR: [ ...(teamId
{ ? {
userId, team: {
}, id: teamId,
{ members: {
team: { some: {
members: { userId,
some: { },
userId,
}, },
}, },
}, }
}, : {
], userId,
teamId: null,
}),
}, },
}); });

View File

@@ -1,8 +1,13 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client'; import type { FieldType, Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type CreateFieldOptions = { export type CreateFieldOptions = {
documentId: number; documentId: number;
userId: number;
teamId?: number;
recipientId: number; recipientId: number;
type: FieldType; type: FieldType;
pageNumber: number; pageNumber: number;
@@ -10,10 +15,13 @@ export type CreateFieldOptions = {
pageY: number; pageY: number;
pageWidth: number; pageWidth: number;
pageHeight: number; pageHeight: number;
requestMetadata?: RequestMetadata;
}; };
export const createField = async ({ export const createField = async ({
documentId, documentId,
userId,
teamId,
recipientId, recipientId,
type, type,
pageNumber, pageNumber,
@@ -21,7 +29,62 @@ export const createField = async ({
pageY, pageY,
pageWidth, pageWidth,
pageHeight, pageHeight,
requestMetadata,
}: CreateFieldOptions) => { }: CreateFieldOptions) => {
const document = await prisma.document.findFirst({
select: {
id: true,
},
where: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
});
if (!document) {
throw new Error('Document not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const field = await prisma.field.create({ const field = await prisma.field.create({
data: { data: {
documentId, documentId,
@@ -35,6 +98,28 @@ export const createField = async ({
customText: '', customText: '',
inserted: false, inserted: false,
}, },
include: {
Recipient: true,
},
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_CREATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: recipientId,
fieldType: field.type,
},
requestMetadata,
}),
}); });
return field; return field;

View File

@@ -1,16 +1,89 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteFieldOptions = { export type DeleteFieldOptions = {
fieldId: number; fieldId: number;
documentId: number; documentId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
}; };
export const deleteField = async ({ fieldId, documentId }: DeleteFieldOptions) => { export const deleteField = async ({
fieldId,
userId,
teamId,
documentId,
requestMetadata,
}: DeleteFieldOptions) => {
const field = await prisma.field.delete({ const field = await prisma.field.delete({
where: { where: {
id: fieldId, id: fieldId,
documentId, Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
}, },
include: {
Recipient: true,
},
});
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirstOrThrow({
where: {
id: teamId,
},
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: field.recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
}); });
return field; return field;

View File

@@ -1,9 +1,14 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { FieldType } from '@documenso/prisma/client'; import type { FieldType, Team } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type UpdateFieldOptions = { export type UpdateFieldOptions = {
fieldId: number; fieldId: number;
documentId: number; documentId: number;
userId: number;
teamId?: number;
recipientId?: number; recipientId?: number;
type?: FieldType; type?: FieldType;
pageNumber?: number; pageNumber?: number;
@@ -11,11 +16,14 @@ export type UpdateFieldOptions = {
pageY?: number; pageY?: number;
pageWidth?: number; pageWidth?: number;
pageHeight?: number; pageHeight?: number;
requestMetadata?: RequestMetadata;
}; };
export const updateField = async ({ export const updateField = async ({
fieldId, fieldId,
documentId, documentId,
userId,
teamId,
recipientId, recipientId,
type, type,
pageNumber, pageNumber,
@@ -23,11 +31,29 @@ export const updateField = async ({
pageY, pageY,
pageWidth, pageWidth,
pageHeight, pageHeight,
requestMetadata,
}: UpdateFieldOptions) => { }: UpdateFieldOptions) => {
const field = await prisma.field.update({ const field = await prisma.field.update({
where: { where: {
id: fieldId, id: fieldId,
documentId, Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
}, },
data: { data: {
recipientId, recipientId,
@@ -38,6 +64,58 @@ export const updateField = async ({
width: pageWidth, width: pageWidth,
height: pageHeight, height: pageHeight,
}, },
include: {
Recipient: true,
},
});
if (!field) {
throw new Error('Field not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
name: true,
email: true,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'FIELD_UPDATED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
fieldId: field.secondaryId,
fieldRecipientEmail: field.Recipient?.email ?? '',
fieldRecipientId: recipientId ?? -1,
fieldType: field.type,
},
requestMetadata,
}),
}); });
return field; return field;

View File

@@ -2,6 +2,7 @@ import type { Duration } from 'luxon';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
// temporary choice for testing only // temporary choice for testing only
import * as timeConstants from '../../constants/time'; import * as timeConstants from '../../constants/time';
@@ -14,14 +15,16 @@ type TimeConstants = typeof timeConstants & {
type CreateApiTokenInput = { type CreateApiTokenInput = {
userId: number; userId: number;
teamId?: number;
tokenName: string; tokenName: string;
expirationDate: string | null; expiresIn: string | null;
}; };
export const createApiToken = async ({ export const createApiToken = async ({
userId, userId,
teamId,
tokenName, tokenName,
expirationDate, expiresIn,
}: CreateApiTokenInput) => { }: CreateApiTokenInput) => {
const apiToken = `api_${alphaid(16)}`; const apiToken = `api_${alphaid(16)}`;
@@ -29,23 +32,36 @@ export const createApiToken = async ({
const timeConstantsRecords: TimeConstants = timeConstants; const timeConstantsRecords: TimeConstants = timeConstants;
const dbToken = await prisma.apiToken.create({ if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
if (!member) {
throw new Error('You do not have permission to create a token for this team');
}
}
const storedToken = await prisma.apiToken.create({
data: { data: {
token: hashedToken,
name: tokenName, name: tokenName,
userId, token: hashedToken,
expires: expirationDate expires: expiresIn ? DateTime.now().plus(timeConstantsRecords[expiresIn]).toJSDate() : null,
? DateTime.now().plus(timeConstantsRecords[expirationDate]).toJSDate() userId: teamId ? null : userId,
: null, teamId,
}, },
}); });
if (!dbToken) { if (!storedToken) {
throw new Error('Failed to create the API token'); throw new Error('Failed to create the API token');
} }
return { return {
id: dbToken.id, id: storedToken.id,
token: apiToken, token: apiToken,
}; };
}; };

View File

@@ -1,15 +1,32 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type DeleteTokenByIdOptions = { export type DeleteTokenByIdOptions = {
id: number; id: number;
userId: number; userId: number;
teamId?: number;
}; };
export const deleteTokenById = async ({ id, userId }: DeleteTokenByIdOptions) => { export const deleteTokenById = async ({ id, userId, teamId }: DeleteTokenByIdOptions) => {
if (teamId) {
const member = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
role: TeamMemberRole.ADMIN,
},
});
if (!member) {
throw new Error('You do not have permission to delete this token');
}
}
return await prisma.apiToken.delete({ return await prisma.apiToken.delete({
where: { where: {
id, id,
userId, userId: teamId ? null : userId,
teamId,
}, },
}); });
}; };

View File

@@ -0,0 +1,36 @@
import { prisma } from '@documenso/prisma';
import { TeamMemberRole } from '@documenso/prisma/client';
export type GetUserTokensOptions = {
userId: number;
teamId: number;
};
export const getTeamTokens = async ({ userId, teamId }: GetUserTokensOptions) => {
const teamMember = await prisma.teamMember.findFirst({
where: {
userId,
teamId,
},
});
if (teamMember?.role !== TeamMemberRole.ADMIN) {
throw new Error('You do not have permission to view tokens for this team');
}
return await prisma.apiToken.findMany({
where: {
teamId,
},
select: {
id: true,
name: true,
algorithm: true,
createdAt: true,
expires: true,
},
orderBy: {
createdAt: 'desc',
},
});
};

View File

@@ -0,0 +1,41 @@
import { prisma } from '@documenso/prisma';
import { hashString } from '../auth/hash';
export const getApiTokenByToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const apiToken = await prisma.apiToken.findFirst({
where: {
token: hashedToken,
},
include: {
team: true,
user: true,
},
});
if (!apiToken) {
throw new Error('Invalid token');
}
if (apiToken.expires && apiToken.expires < new Date()) {
throw new Error('Expired token');
}
if (apiToken.team) {
apiToken.user = await prisma.user.findFirst({
where: {
id: apiToken.team.ownerUserId,
},
});
}
const { user } = apiToken;
if (!user) {
throw new Error('Invalid token');
}
return { ...apiToken, user };
};

View File

@@ -1,37 +0,0 @@
import { prisma } from '@documenso/prisma';
import { hashString } from '../auth/hash';
export const getUserByApiToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const user = await prisma.user.findFirst({
where: {
ApiToken: {
some: {
token: hashedToken,
},
},
},
include: {
ApiToken: true,
},
});
if (!user) {
throw new Error('Invalid token');
}
const retrievedToken = user.ApiToken.find((apiToken) => apiToken.token === hashedToken);
// This should be impossible but we need to satisfy TypeScript
if (!retrievedToken) {
throw new Error('Invalid token');
}
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
throw new Error('Expired token');
}
return user;
};

View File

@@ -1,16 +1,46 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { SendStatus } from '@documenso/prisma/client'; import { SendStatus } from '@documenso/prisma/client';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export type DeleteRecipientOptions = { export type DeleteRecipientOptions = {
documentId: number; documentId: number;
recipientId: number; recipientId: number;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
}; };
export const deleteRecipient = async ({ documentId, recipientId }: DeleteRecipientOptions) => { export const deleteRecipient = async ({
documentId,
recipientId,
userId,
teamId,
requestMetadata,
}: DeleteRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({ const recipient = await prisma.recipient.findFirst({
where: { where: {
id: recipientId, id: recipientId,
documentId, Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
}, },
}); });
@@ -22,11 +52,55 @@ export const deleteRecipient = async ({ documentId, recipientId }: DeleteRecipie
throw new Error('Can not delete a recipient that has already been sent a document'); throw new Error('Can not delete a recipient that has already been sent a document');
} }
const deletedRecipient = await prisma.recipient.delete({ const user = await prisma.user.findFirstOrThrow({
where: { where: {
id: recipient.id, id: userId,
}, },
}); });
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const deletedRecipient = await prisma.$transaction(async (tx) => {
const deleted = await tx.recipient.delete({
where: {
id: recipient.id,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: 'RECIPIENT_DELETED',
documentId,
user: {
id: team?.id ?? user.id,
email: team?.name ?? user.email,
name: team ? '' : user.name,
},
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
},
requestMetadata,
}),
});
return deleted;
});
return deletedRecipient; return deletedRecipient;
}; };

View File

@@ -3,11 +3,13 @@ import { prisma } from '@documenso/prisma';
export interface GetRecipientsForDocumentOptions { export interface GetRecipientsForDocumentOptions {
documentId: number; documentId: number;
userId: number; userId: number;
teamId?: number;
} }
export const getRecipientsForDocument = async ({ export const getRecipientsForDocument = async ({
documentId, documentId,
userId, userId,
teamId,
}: GetRecipientsForDocumentOptions) => { }: GetRecipientsForDocumentOptions) => {
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
where: { where: {
@@ -18,6 +20,7 @@ export const getRecipientsForDocument = async ({
userId, userId,
}, },
{ {
teamId,
team: { team: {
members: { members: {
some: { some: {

View File

@@ -11,6 +11,7 @@ import { SendStatus, SigningStatus } from '@documenso/prisma/client';
export interface SetRecipientsForDocumentOptions { export interface SetRecipientsForDocumentOptions {
userId: number; userId: number;
teamId?: number;
documentId: number; documentId: number;
recipients: { recipients: {
id?: number | null; id?: number | null;
@@ -23,6 +24,7 @@ export interface SetRecipientsForDocumentOptions {
export const setRecipientsForDocument = async ({ export const setRecipientsForDocument = async ({
userId, userId,
teamId,
documentId, documentId,
recipients, recipients,
requestMetadata, requestMetadata,
@@ -30,20 +32,21 @@ export const setRecipientsForDocument = async ({
const document = await prisma.document.findFirst({ const document = await prisma.document.findFirst({
where: { where: {
id: documentId, id: documentId,
OR: [ ...(teamId
{ ? {
userId, team: {
}, id: teamId,
{ members: {
team: { some: {
members: { userId,
some: { },
userId,
}, },
}, },
}, }
}, : {
], userId,
teamId: null,
}),
}, },
}); });
@@ -106,7 +109,7 @@ export const setRecipientsForDocument = async ({
}); });
const persistedRecipients = await prisma.$transaction(async (tx) => { const persistedRecipients = await prisma.$transaction(async (tx) => {
await Promise.all( return await Promise.all(
linkedRecipients.map(async (recipient) => { linkedRecipients.map(async (recipient) => {
const upsertedRecipient = await tx.recipient.upsert({ const upsertedRecipient = await tx.recipient.upsert({
where: { where: {

View File

@@ -1,5 +1,9 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { RecipientRole } from '@documenso/prisma/client'; import type { RecipientRole, Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
export type UpdateRecipientOptions = { export type UpdateRecipientOptions = {
documentId: number; documentId: number;
@@ -7,6 +11,9 @@ export type UpdateRecipientOptions = {
email?: string; email?: string;
name?: string; name?: string;
role?: RecipientRole; role?: RecipientRole;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
}; };
export const updateRecipient = async ({ export const updateRecipient = async ({
@@ -15,11 +22,52 @@ export const updateRecipient = async ({
email, email,
name, name,
role, role,
userId,
teamId,
requestMetadata,
}: UpdateRecipientOptions) => { }: UpdateRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({ const recipient = await prisma.recipient.findFirst({
where: { where: {
id: recipientId, id: recipientId,
documentId, Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
}, },
}); });
@@ -27,15 +75,43 @@ export const updateRecipient = async ({
throw new Error('Recipient not found'); throw new Error('Recipient not found');
} }
const updatedRecipient = await prisma.recipient.update({ const updatedRecipient = await prisma.$transaction(async (tx) => {
where: { const persisted = await prisma.recipient.update({
id: recipient.id, where: {
}, id: recipient.id,
data: { },
email: email?.toLowerCase() ?? recipient.email, data: {
name: name ?? recipient.name, email: email?.toLowerCase() ?? recipient.email,
role: role ?? recipient.role, name: name ?? recipient.name,
}, role: role ?? recipient.role,
},
});
const changes = diffRecipientChanges(recipient, persisted);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user: {
id: team?.id ?? user.id,
name: team?.name ?? user.name,
email: team ? '' : user.email,
},
requestMetadata,
data: {
changes,
recipientId,
recipientEmail: persisted.email,
recipientName: persisted.name,
recipientRole: persisted.role,
},
}),
});
return persisted;
}
}); });
return updatedRecipient; return updatedRecipient;

View File

@@ -5,6 +5,7 @@ import type { RecipientRole } from '@documenso/prisma/client';
export type CreateDocumentFromTemplateOptions = { export type CreateDocumentFromTemplateOptions = {
templateId: number; templateId: number;
userId: number; userId: number;
teamId?: number;
recipients?: { recipients?: {
name?: string; name?: string;
email: string; email: string;
@@ -15,25 +16,27 @@ export type CreateDocumentFromTemplateOptions = {
export const createDocumentFromTemplate = async ({ export const createDocumentFromTemplate = async ({
templateId, templateId,
userId, userId,
teamId,
recipients, recipients,
}: CreateDocumentFromTemplateOptions) => { }: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: { where: {
id: templateId, id: templateId,
OR: [ ...(teamId
{ ? {
userId, team: {
}, id: teamId,
{ members: {
team: { some: {
members: { userId,
some: { },
userId,
}, },
}, },
}, }
}, : {
], userId,
teamId: null,
}),
}, },
include: { include: {
Recipient: true, Recipient: true,

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "ApiToken" ADD COLUMN "teamId" INTEGER,
ALTER COLUMN "userId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -19,19 +19,19 @@ enum Role {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String? name String?
customerId String? @unique customerId String? @unique
email String @unique email String @unique
emailVerified DateTime? emailVerified DateTime?
password String? password String?
source String? source String?
signature String? signature String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
lastSignedIn DateTime @default(now()) lastSignedIn DateTime @default(now())
roles Role[] @default([USER]) roles Role[] @default([USER])
identityProvider IdentityProvider @default(DOCUMENSO) identityProvider IdentityProvider @default(DOCUMENSO)
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
Document Document[] Document Document[]
@@ -41,12 +41,12 @@ model User {
ownedPendingTeams TeamPending[] ownedPendingTeams TeamPending[]
teamMembers TeamMember[] teamMembers TeamMember[]
twoFactorSecret String? twoFactorSecret String?
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String? twoFactorBackupCodes String?
VerificationToken VerificationToken[] VerificationToken VerificationToken[]
ApiToken ApiToken[] ApiToken ApiToken[]
Template Template[] Template Template[]
securityAuditLogs UserSecurityAuditLog[] securityAuditLogs UserSecurityAuditLog[]
@@index([email]) @@index([email])
} }
@@ -105,8 +105,10 @@ model ApiToken {
algorithm ApiTokenAlgorithm @default(SHA512) algorithm ApiTokenAlgorithm @default(SHA512)
expires DateTime? expires DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
userId Int userId Int?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
} }
enum SubscriptionStatus { enum SubscriptionStatus {
@@ -225,15 +227,15 @@ model DocumentData {
} }
model DocumentMeta { model DocumentMeta {
id String @id @default(cuid()) id String @id @default(cuid())
subject String? subject String?
message String? message String?
timezone String? @default("Etc/UTC") @db.Text timezone String? @default("Etc/UTC") @db.Text
password String? password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String? redirectUrl String?
} }
enum ReadStatus { enum ReadStatus {
@@ -372,6 +374,7 @@ model Team {
document Document[] document Document[]
templates Template[] templates Template[]
ApiToken ApiToken[]
} }
model TeamPending { model TeamPending {

View File

@@ -46,12 +46,13 @@ export const apiTokenRouter = router({
.input(ZCreateTokenMutationSchema) .input(ZCreateTokenMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { tokenName, expirationDate } = input; const { tokenName, teamId, expirationDate } = input;
return await createApiToken({ return await createApiToken({
userId: ctx.user.id, userId: ctx.user.id,
teamId,
tokenName, tokenName,
expirationDate, expiresIn: expirationDate,
}); });
} catch (e) { } catch (e) {
throw new TRPCError({ throw new TRPCError({
@@ -65,10 +66,11 @@ export const apiTokenRouter = router({
.input(ZDeleteTokenByIdMutationSchema) .input(ZDeleteTokenByIdMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { id } = input; const { id, teamId } = input;
return await deleteTokenById({ return await deleteTokenById({
id, id,
teamId,
userId: ctx.user.id, userId: ctx.user.id,
}); });
} catch (e) { } catch (e) {

View File

@@ -7,6 +7,7 @@ export const ZGetApiTokenByIdQuerySchema = z.object({
export type TGetApiTokenByIdQuerySchema = z.infer<typeof ZGetApiTokenByIdQuerySchema>; export type TGetApiTokenByIdQuerySchema = z.infer<typeof ZGetApiTokenByIdQuerySchema>;
export const ZCreateTokenMutationSchema = z.object({ export const ZCreateTokenMutationSchema = z.object({
teamId: z.number().optional(),
tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }), tokenName: z.string().min(3, { message: 'The token name should be 3 characters or longer' }),
expirationDate: z.string().nullable(), expirationDate: z.string().nullable(),
}); });
@@ -15,6 +16,7 @@ export type TCreateTokenMutationSchema = z.infer<typeof ZCreateTokenMutationSche
export const ZDeleteTokenByIdMutationSchema = z.object({ export const ZDeleteTokenByIdMutationSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
teamId: z.number().optional(),
}); });
export type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenByIdMutationSchema>; export type TDeleteTokenByIdMutationSchema = z.infer<typeof ZDeleteTokenByIdMutationSchema>;

View File

@@ -107,11 +107,11 @@ export const documentRouter = router({
.input(ZDeleteDraftDocumentMutationSchema) .input(ZDeleteDraftDocumentMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
const { id, status } = input; const { id } = input;
const userId = ctx.user.id; const userId = ctx.user.id;
return await deleteDocument({ id, userId, status }); return await deleteDocument({ id, userId });
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { URL_REGEX } from '@documenso/lib/constants/url-regex'; import { URL_REGEX } from '@documenso/lib/constants/url-regex';
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client'; import { FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({ export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1), id: z.number().min(1),
@@ -102,7 +102,6 @@ export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSc
export const ZDeleteDraftDocumentMutationSchema = z.object({ export const ZDeleteDraftDocumentMutationSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
status: z.nativeEnum(DocumentStatus),
}); });
export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocumentMutationSchema>; export type TDeleteDraftDocumentMutationSchema = z.infer<typeof ZDeleteDraftDocumentMutationSchema>;