mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 01:32:06 +10:00
feat: support embedded authoring for creation (#1741)
Adds support for creating documents and templates using our embed components. Support is super primitive at the moment and is being polished.
This commit is contained in:
203
packages/app-tests/e2e/api/v2/embedding-presign.spec.ts
Normal file
203
packages/app-tests/e2e/api/v2/embedding-presign.spec.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
test.describe('Embedding Presign API', () => {
|
||||
test('createEmbeddingPresignToken: should create a token with default expiration', async ({
|
||||
request,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
console.log(responseData);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.token).toBeDefined();
|
||||
expect(responseData.expiresAt).toBeDefined();
|
||||
expect(responseData.expiresIn).toBe(3600); // Default 1 hour in seconds
|
||||
});
|
||||
|
||||
test('createEmbeddingPresignToken: should create a token with custom expiration', async ({
|
||||
request,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
expiresIn: 120, // 2 hours
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
console.log(responseData);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.token).toBeDefined();
|
||||
expect(responseData.expiresAt).toBeDefined();
|
||||
expect(responseData.expiresIn).toBe(7200); // 2 hours in seconds
|
||||
});
|
||||
|
||||
test('createEmbeddingPresignToken: should create a token with immediate expiration in dev mode', async ({
|
||||
request,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
expiresIn: 0, // Immediate expiration
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
console.log(responseData);
|
||||
|
||||
expect(responseData.token).toBeDefined();
|
||||
expect(responseData.expiresAt).toBeDefined();
|
||||
expect(responseData.expiresIn).toBe(0); // 0 seconds
|
||||
});
|
||||
|
||||
test('verifyEmbeddingPresignToken: should verify a valid token', async ({ request }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// First create a token
|
||||
const createResponse = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
apiToken: token,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(createResponse.ok()).toBeTruthy();
|
||||
const createResponseData = await createResponse.json();
|
||||
|
||||
console.log('Create response:', createResponseData);
|
||||
|
||||
const presignToken = createResponseData.token;
|
||||
|
||||
// Then verify it
|
||||
const verifyResponse = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
token: presignToken,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(verifyResponse.ok()).toBeTruthy();
|
||||
expect(verifyResponse.status()).toBe(200);
|
||||
|
||||
const verifyResponseData = await verifyResponse.json();
|
||||
|
||||
console.log('Verify response:', verifyResponseData);
|
||||
|
||||
expect(verifyResponseData.success).toBe(true);
|
||||
});
|
||||
|
||||
test('verifyEmbeddingPresignToken: should reject an invalid token', async ({ request }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { token } = await createApiToken({
|
||||
userId: user.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const response = await request.post(
|
||||
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
token: 'invalid-token',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
console.log('Invalid token response:', responseData);
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
expect(responseData.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
|
||||
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
@ -6,7 +6,7 @@ import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices';
|
||||
|
||||
export type IsDocumentPlatformOptions = Pick<Document, 'id' | 'userId' | 'teamId'>;
|
||||
export type IsDocumentPlatformOptions = Pick<Document, 'userId' | 'teamId'>;
|
||||
|
||||
/**
|
||||
* Whether the user is platform, or has permission to use platform features on
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"@lingui/core": "^5.2.0",
|
||||
"@lingui/macro": "^5.2.0",
|
||||
"@lingui/react": "^5.2.0",
|
||||
"jose": "^6.0.0",
|
||||
"@noble/ciphers": "0.4.0",
|
||||
"@noble/hashes": "1.3.2",
|
||||
"@node-rs/bcrypt": "^1.10.0",
|
||||
@ -58,4 +59,4 @@
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@types/pg": "^8.11.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
import { SignJWT } from 'jose';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { env } from '../../utils/env';
|
||||
import { getApiTokenByToken } from '../public-api/get-api-token-by-token';
|
||||
|
||||
export type CreateEmbeddingPresignTokenOptions = {
|
||||
apiToken: string;
|
||||
/**
|
||||
* Number of hours until the token expires
|
||||
* In development mode, can be set to 0 to create a token that expires immediately (for testing)
|
||||
*/
|
||||
expiresIn?: number;
|
||||
};
|
||||
|
||||
export const createEmbeddingPresignToken = async ({
|
||||
apiToken,
|
||||
expiresIn,
|
||||
}: CreateEmbeddingPresignTokenOptions) => {
|
||||
try {
|
||||
// Validate the API token
|
||||
const validatedToken = await getApiTokenByToken({ token: apiToken });
|
||||
|
||||
const now = DateTime.now();
|
||||
|
||||
// In development mode, allow setting expiresIn to 0 for testing
|
||||
// In production, enforce a minimum expiration time
|
||||
const isDevelopment = env('NODE_ENV') !== 'production';
|
||||
console.log('isDevelopment', isDevelopment);
|
||||
const minExpirationMinutes = isDevelopment ? 0 : 5;
|
||||
|
||||
// Ensure expiresIn is at least the minimum allowed value
|
||||
const effectiveExpiresIn =
|
||||
expiresIn !== undefined && expiresIn >= minExpirationMinutes ? expiresIn : 60; // Default to 1 hour if not specified or below minimum
|
||||
|
||||
const expiresAt = now.plus({ minutes: effectiveExpiresIn });
|
||||
|
||||
const secret = new TextEncoder().encode(validatedToken.token);
|
||||
|
||||
const token = await new SignJWT({
|
||||
aud: String(validatedToken.teamId ?? validatedToken.userId),
|
||||
sub: String(validatedToken.id),
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt(now.toJSDate())
|
||||
.setExpirationTime(expiresAt.toJSDate())
|
||||
.sign(secret);
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt: expiresAt.toJSDate(),
|
||||
expiresIn: Math.floor(expiresAt.diff(now).toMillis() / 1000),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create presign token',
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,115 @@
|
||||
import type { JWTPayload } from 'jose';
|
||||
import { decodeJwt, jwtVerify } from 'jose';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
|
||||
export type VerifyEmbeddingPresignTokenOptions = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
export const verifyEmbeddingPresignToken = async ({
|
||||
token,
|
||||
}: VerifyEmbeddingPresignTokenOptions) => {
|
||||
// First decode the JWT to get the claims without verification
|
||||
let decodedToken: JWTPayload;
|
||||
|
||||
try {
|
||||
decodedToken = decodeJwt<JWTPayload>(token);
|
||||
} catch (error) {
|
||||
console.error('Error decoding JWT token:', error);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid presign token format',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate the required claims
|
||||
if (!decodedToken.sub || typeof decodedToken.sub !== 'string') {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid presign token format: missing or invalid subject claim',
|
||||
});
|
||||
}
|
||||
|
||||
if (!decodedToken.aud || typeof decodedToken.aud !== 'string') {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid presign token format: missing or invalid audience claim',
|
||||
});
|
||||
}
|
||||
|
||||
// Convert string IDs to numbers
|
||||
const tokenId = Number(decodedToken.sub);
|
||||
const audienceId = Number(decodedToken.aud);
|
||||
|
||||
if (Number.isNaN(tokenId) || !Number.isInteger(tokenId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token ID format in subject claim',
|
||||
});
|
||||
}
|
||||
|
||||
if (Number.isNaN(audienceId) || !Number.isInteger(audienceId)) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid user ID format in audience claim',
|
||||
});
|
||||
}
|
||||
|
||||
// Get the API token to use as the verification secret
|
||||
const apiToken = await prisma.apiToken.findFirst({
|
||||
where: {
|
||||
id: tokenId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid presign token: API token not found',
|
||||
});
|
||||
}
|
||||
|
||||
// This should never happen but we need to narrow types
|
||||
if (!apiToken.userId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid presign token: API token does not have a user attached',
|
||||
});
|
||||
}
|
||||
|
||||
const userId = apiToken.userId;
|
||||
|
||||
if (audienceId !== apiToken.teamId && audienceId !== apiToken.userId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid presign token: API token does not match audience',
|
||||
});
|
||||
}
|
||||
|
||||
// Now verify the token with the actual secret
|
||||
const secret = new TextEncoder().encode(apiToken.token);
|
||||
|
||||
try {
|
||||
await jwtVerify(token, secret);
|
||||
} catch (error) {
|
||||
// Check if the token has expired
|
||||
if (error instanceof Error && error.name === 'JWTExpired') {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Presign token has expired',
|
||||
});
|
||||
}
|
||||
|
||||
// Handle invalid signature
|
||||
if (error instanceof Error && error.name === 'JWSInvalid') {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid presign token signature',
|
||||
});
|
||||
}
|
||||
|
||||
// Log and rethrow other errors
|
||||
console.error('Error verifying JWT token:', error);
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Failed to verify presign token',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...apiToken,
|
||||
userId,
|
||||
};
|
||||
};
|
||||
14
packages/trpc/server/embedding-router/_router.ts
Normal file
14
packages/trpc/server/embedding-router/_router.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { router } from '../trpc';
|
||||
import { createEmbeddingDocumentRoute } from './create-embedding-document';
|
||||
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
|
||||
import { createEmbeddingTemplateRoute } from './create-embedding-template';
|
||||
import { getEmbeddingDocumentRoute } from './get-embedding-document';
|
||||
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
|
||||
|
||||
export const embeddingPresignRouter = router({
|
||||
createEmbeddingPresignToken: createEmbeddingPresignTokenRoute,
|
||||
verifyEmbeddingPresignToken: verifyEmbeddingPresignTokenRoute,
|
||||
createEmbeddingDocument: createEmbeddingDocumentRoute,
|
||||
createEmbeddingTemplate: createEmbeddingTemplateRoute,
|
||||
getEmbeddingDocument: getEmbeddingDocumentRoute,
|
||||
});
|
||||
@ -0,0 +1,63 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createDocumentV2 } from '@documenso/lib/server-only/document/create-document-v2';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEmbeddingDocumentRequestSchema,
|
||||
ZCreateEmbeddingDocumentResponseSchema,
|
||||
} from './create-embedding-document.types';
|
||||
|
||||
export const createEmbeddingDocumentRoute = procedure
|
||||
.input(ZCreateEmbeddingDocumentRequestSchema)
|
||||
.output(ZCreateEmbeddingDocumentResponseSchema)
|
||||
.mutation(async ({ input, ctx: { req, metadata } }) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '')
|
||||
.split('Bearer ')
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
|
||||
const { title, documentDataId, externalId, recipients, meta } = input;
|
||||
|
||||
const document = await createDocumentV2({
|
||||
data: {
|
||||
title,
|
||||
externalId,
|
||||
recipients,
|
||||
},
|
||||
meta,
|
||||
documentDataId,
|
||||
userId: apiToken.userId,
|
||||
teamId: apiToken.teamId ?? undefined,
|
||||
requestMetadata: metadata,
|
||||
});
|
||||
|
||||
if (!document.id) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create document: missing document ID',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
documentId: document.id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create document',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,83 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import {
|
||||
ZFieldHeightSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
ZFieldPageXSchema,
|
||||
ZFieldPageYSchema,
|
||||
ZFieldWidthSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
import { DocumentSigningOrder } from '@documenso/prisma/generated/types';
|
||||
|
||||
import {
|
||||
ZDocumentExternalIdSchema,
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
ZDocumentMetaTypedSignatureEnabledSchema,
|
||||
ZDocumentMetaUploadSignatureEnabledSchema,
|
||||
ZDocumentTitleSchema,
|
||||
} from '../document-router/schema';
|
||||
import { ZCreateRecipientSchema } from '../recipient-router/schema';
|
||||
|
||||
export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
documentDataId: z.string(),
|
||||
externalId: ZDocumentExternalIdSchema.optional(),
|
||||
recipients: z
|
||||
.array(
|
||||
ZCreateRecipientSchema.extend({
|
||||
fields: ZFieldAndMetaSchema.and(
|
||||
z.object({
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
}),
|
||||
)
|
||||
.array()
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(recipients) => {
|
||||
const emails = recipients.map((recipient) => recipient.email);
|
||||
|
||||
return new Set(emails).size === emails.length;
|
||||
},
|
||||
{ message: 'Recipients must have unique emails' },
|
||||
)
|
||||
.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
subject: ZDocumentMetaSubjectSchema.optional(),
|
||||
message: ZDocumentMetaMessageSchema.optional(),
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingDocumentResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateEmbeddingDocumentRequestSchema = z.infer<
|
||||
typeof ZCreateEmbeddingDocumentRequestSchema
|
||||
>;
|
||||
@ -0,0 +1,73 @@
|
||||
import { isCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
|
||||
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
|
||||
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEmbeddingPresignTokenRequestSchema,
|
||||
ZCreateEmbeddingPresignTokenResponseSchema,
|
||||
createEmbeddingPresignTokenMeta,
|
||||
} from './create-embedding-presign-token.types';
|
||||
|
||||
/**
|
||||
* Route to create embedding presign tokens.
|
||||
*/
|
||||
export const createEmbeddingPresignTokenRoute = procedure
|
||||
.meta(createEmbeddingPresignTokenMeta)
|
||||
.input(ZCreateEmbeddingPresignTokenRequestSchema)
|
||||
.output(ZCreateEmbeddingPresignTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx: { req } }) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
const [apiToken] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!apiToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No API token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const { expiresIn } = input;
|
||||
|
||||
if (IS_BILLING_ENABLED()) {
|
||||
const token = await getApiTokenByToken({ token: apiToken });
|
||||
|
||||
if (!token.userId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid API token',
|
||||
});
|
||||
}
|
||||
|
||||
const [hasCommunityPlan, hasPlatformPlan, hasEnterprisePlan] = await Promise.all([
|
||||
isCommunityPlan({ userId: token.userId, teamId: token.teamId ?? undefined }),
|
||||
isDocumentPlatform({ userId: token.userId, teamId: token.teamId }),
|
||||
isUserEnterprise({ userId: token.userId, teamId: token.teamId ?? undefined }),
|
||||
]);
|
||||
|
||||
if (!hasCommunityPlan && !hasPlatformPlan && !hasEnterprisePlan) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You do not have permission to create embedding presign tokens',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const presignToken = await createEmbeddingPresignToken({
|
||||
apiToken,
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return { ...presignToken };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create embedding presign token',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const createEmbeddingPresignTokenMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/embedding/create-presign-token',
|
||||
summary: 'Create embedding presign token',
|
||||
description:
|
||||
'Creates a presign token for embedding operations with configurable expiration time',
|
||||
tags: ['Embedding'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZCreateEmbeddingPresignTokenRequestSchema = z.object({
|
||||
expiresIn: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(10080)
|
||||
.optional()
|
||||
.default(60)
|
||||
.describe('Expiration time in minutes (default: 60, max: 10,080)'),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingPresignTokenResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
expiresAt: z.date(),
|
||||
expiresIn: z.number().describe('Expiration time in seconds'),
|
||||
});
|
||||
|
||||
export type TCreateEmbeddingPresignTokenRequestSchema = z.infer<
|
||||
typeof ZCreateEmbeddingPresignTokenRequestSchema
|
||||
>;
|
||||
|
||||
export type TCreateEmbeddingPresignTokenResponseSchema = z.infer<
|
||||
typeof ZCreateEmbeddingPresignTokenResponseSchema
|
||||
>;
|
||||
@ -0,0 +1,112 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { createTemplate } from '@documenso/lib/server-only/template/create-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZCreateEmbeddingTemplateRequestSchema,
|
||||
ZCreateEmbeddingTemplateResponseSchema,
|
||||
} from './create-embedding-template.types';
|
||||
|
||||
export const createEmbeddingTemplateRoute = procedure
|
||||
.input(ZCreateEmbeddingTemplateRequestSchema)
|
||||
.output(ZCreateEmbeddingTemplateResponseSchema)
|
||||
.mutation(async ({ input, ctx: { req } }) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '')
|
||||
.split('Bearer ')
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
|
||||
const { title, documentDataId, recipients, meta } = input;
|
||||
|
||||
// First create the template
|
||||
const template = await createTemplate({
|
||||
userId: apiToken.userId,
|
||||
title,
|
||||
templateDocumentDataId: documentDataId,
|
||||
teamId: apiToken.teamId ?? undefined,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
recipients.map(async (recipient) => {
|
||||
const createdRecipient = await prisma.recipient.create({
|
||||
data: {
|
||||
templateId: template.id,
|
||||
email: recipient.email,
|
||||
name: recipient.name || '',
|
||||
role: recipient.role || 'SIGNER',
|
||||
token: `template-${template.id}-${recipient.email}`,
|
||||
signingOrder: recipient.signingOrder,
|
||||
},
|
||||
});
|
||||
|
||||
const fields = recipient.fields ?? [];
|
||||
|
||||
const createdFields = await prisma.field.createMany({
|
||||
data: fields.map((field) => ({
|
||||
recipientId: createdRecipient.id,
|
||||
type: field.type,
|
||||
page: field.pageNumber,
|
||||
positionX: field.pageX,
|
||||
positionY: field.pageY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
templateId: template.id,
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
...createdRecipient,
|
||||
fields: createdFields,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Update the template meta if needed
|
||||
if (meta) {
|
||||
await prisma.templateMeta.upsert({
|
||||
where: {
|
||||
templateId: template.id,
|
||||
},
|
||||
create: {
|
||||
templateId: template.id,
|
||||
...meta,
|
||||
},
|
||||
update: {
|
||||
...meta,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!template.id) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create template: missing template ID',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
templateId: template.id,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to create template',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,74 @@
|
||||
import { DocumentSigningOrder, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||
import {
|
||||
ZFieldHeightSchema,
|
||||
ZFieldPageNumberSchema,
|
||||
ZFieldPageXSchema,
|
||||
ZFieldPageYSchema,
|
||||
ZFieldWidthSchema,
|
||||
} from '@documenso/lib/types/field';
|
||||
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||
|
||||
import {
|
||||
ZDocumentMetaDateFormatSchema,
|
||||
ZDocumentMetaDistributionMethodSchema,
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
ZDocumentMetaTypedSignatureEnabledSchema,
|
||||
ZDocumentMetaUploadSignatureEnabledSchema,
|
||||
ZDocumentTitleSchema,
|
||||
} from '../document-router/schema';
|
||||
|
||||
const ZFieldSchema = z.object({
|
||||
type: z.nativeEnum(FieldType),
|
||||
pageNumber: ZFieldPageNumberSchema,
|
||||
pageX: ZFieldPageXSchema,
|
||||
pageY: ZFieldPageYSchema,
|
||||
width: ZFieldWidthSchema,
|
||||
height: ZFieldHeightSchema,
|
||||
fieldMeta: ZFieldMetaSchema.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
||||
title: ZDocumentTitleSchema,
|
||||
documentDataId: z.string(),
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
role: z.nativeEnum(RecipientRole).optional(),
|
||||
signingOrder: z.number().optional(),
|
||||
fields: z.array(ZFieldSchema).optional(),
|
||||
}),
|
||||
),
|
||||
meta: z
|
||||
.object({
|
||||
subject: ZDocumentMetaSubjectSchema.optional(),
|
||||
message: ZDocumentMetaMessageSchema.optional(),
|
||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||
dateFormat: ZDocumentMetaDateFormatSchema.optional(),
|
||||
distributionMethod: ZDocumentMetaDistributionMethodSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
redirectUrl: ZDocumentMetaRedirectUrlSchema.optional(),
|
||||
language: ZDocumentMetaLanguageSchema.optional(),
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZCreateEmbeddingTemplateResponseSchema = z.object({
|
||||
templateId: z.number(),
|
||||
});
|
||||
|
||||
export type TCreateEmbeddingTemplateRequestSchema = z.infer<
|
||||
typeof ZCreateEmbeddingTemplateRequestSchema
|
||||
>;
|
||||
@ -0,0 +1,63 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZGetEmbeddingDocumentRequestSchema,
|
||||
ZGetEmbeddingDocumentResponseSchema,
|
||||
} from './get-embedding-document.types';
|
||||
|
||||
export const getEmbeddingDocumentRoute = procedure
|
||||
.input(ZGetEmbeddingDocumentRequestSchema)
|
||||
.output(ZGetEmbeddingDocumentResponseSchema)
|
||||
.query(async ({ input, ctx: { req } }) => {
|
||||
try {
|
||||
const authorizationHeader = req.headers.get('authorization');
|
||||
|
||||
const [presignToken] = (authorizationHeader || '')
|
||||
.split('Bearer ')
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
if (!presignToken) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'No presign token provided',
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({ token: presignToken });
|
||||
|
||||
const { documentId } = input;
|
||||
|
||||
const document = await prisma.document.findFirst({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId: apiToken.userId,
|
||||
...(apiToken.teamId ? { teamId: apiToken.teamId } : {}),
|
||||
},
|
||||
include: {
|
||||
documentData: true,
|
||||
recipients: true,
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Document not found',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
document,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to get document',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,34 @@
|
||||
import { DocumentDataType, type Field, type Recipient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetEmbeddingDocumentRequestSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export const ZGetEmbeddingDocumentResponseSchema = z.object({
|
||||
document: z
|
||||
.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
documentDataId: z.string(),
|
||||
userId: z.number(),
|
||||
teamId: z.number().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
documentData: z.object({
|
||||
id: z.string(),
|
||||
type: z.nativeEnum(DocumentDataType),
|
||||
data: z.string(),
|
||||
initialData: z.string(),
|
||||
}),
|
||||
recipients: z.array(z.custom<Recipient>()),
|
||||
fields: z.array(z.custom<Field>()),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type TGetEmbeddingDocumentRequestSchema = z.infer<typeof ZGetEmbeddingDocumentRequestSchema>;
|
||||
export type TGetEmbeddingDocumentResponseSchema = z.infer<
|
||||
typeof ZGetEmbeddingDocumentResponseSchema
|
||||
>;
|
||||
@ -0,0 +1,36 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
|
||||
|
||||
import { procedure } from '../trpc';
|
||||
import {
|
||||
ZVerifyEmbeddingPresignTokenRequestSchema,
|
||||
ZVerifyEmbeddingPresignTokenResponseSchema,
|
||||
verifyEmbeddingPresignTokenMeta,
|
||||
} from './verify-embedding-presign-token.types';
|
||||
|
||||
/**
|
||||
* Public route.
|
||||
*/
|
||||
export const verifyEmbeddingPresignTokenRoute = procedure
|
||||
.meta(verifyEmbeddingPresignTokenMeta)
|
||||
.input(ZVerifyEmbeddingPresignTokenRequestSchema)
|
||||
.output(ZVerifyEmbeddingPresignTokenResponseSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const { token } = input;
|
||||
|
||||
const apiToken = await verifyEmbeddingPresignToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
return { success: !!apiToken };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to verify embedding presign token',
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../trpc';
|
||||
|
||||
export const verifyEmbeddingPresignTokenMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/embedding/verify-presign-token',
|
||||
summary: 'Verify embedding presign token',
|
||||
description:
|
||||
'Verifies a presign token for embedding operations and returns the associated API token',
|
||||
tags: ['Embedding'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZVerifyEmbeddingPresignTokenRequestSchema = z.object({
|
||||
token: z
|
||||
.string()
|
||||
.min(1, { message: 'Token is required' })
|
||||
.describe('The presign token to verify'),
|
||||
});
|
||||
|
||||
export const ZVerifyEmbeddingPresignTokenResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
export type TVerifyEmbeddingPresignTokenRequestSchema = z.infer<
|
||||
typeof ZVerifyEmbeddingPresignTokenRequestSchema
|
||||
>;
|
||||
|
||||
export type TVerifyEmbeddingPresignTokenResponseSchema = z.infer<
|
||||
typeof ZVerifyEmbeddingPresignTokenResponseSchema
|
||||
>;
|
||||
@ -2,6 +2,7 @@ import { adminRouter } from './admin-router/router';
|
||||
import { apiTokenRouter } from './api-token-router/router';
|
||||
import { authRouter } from './auth-router/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { embeddingPresignRouter } from './embedding-router/_router';
|
||||
import { fieldRouter } from './field-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { recipientRouter } from './recipient-router/router';
|
||||
@ -23,6 +24,7 @@ export const appRouter = router({
|
||||
team: teamRouter,
|
||||
template: templateRouter,
|
||||
webhook: webhookRouter,
|
||||
embeddingPresign: embeddingPresignRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@ -8,14 +8,11 @@ import type { Field, Recipient } from '@prisma/client';
|
||||
import { FieldType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import {
|
||||
CalendarDays,
|
||||
Check,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Contact,
|
||||
Disc,
|
||||
Hash,
|
||||
Info,
|
||||
Mail,
|
||||
Type,
|
||||
User,
|
||||
@ -27,7 +24,6 @@ import { prop, sortBy } from 'remeda';
|
||||
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import {
|
||||
type TFieldMetaSchema as FieldMeta,
|
||||
ZFieldMetaSchema,
|
||||
@ -42,16 +38,13 @@ import {
|
||||
} from '@documenso/lib/utils/recipients';
|
||||
|
||||
import { FieldToolTip } from '../../components/field/field-tooltip';
|
||||
import { getSignerColorStyles, useSignerColors } from '../../lib/signer-colors';
|
||||
import { useSignerColors } from '../../lib/signer-colors';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Alert, AlertDescription } from '../alert';
|
||||
import { Button } from '../button';
|
||||
import { Card, CardContent } from '../card';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command';
|
||||
import { Form } from '../form/form';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
||||
import { RecipientSelector } from '../recipient-selector';
|
||||
import { useStep } from '../stepper';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip';
|
||||
import { useToast } from '../use-toast';
|
||||
import type { TAddFieldsFormSchema } from './add-fields.types';
|
||||
import {
|
||||
@ -663,123 +656,12 @@ export const AddFieldsFormPartial = ({
|
||||
})}
|
||||
|
||||
{!hideRecipients && (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'bg-background text-muted-foreground hover:text-foreground mb-12 mt-2 justify-between font-normal',
|
||||
selectedSignerStyles.default.base,
|
||||
)}
|
||||
>
|
||||
{selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedSigner?.name} ({selectedSigner?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!selectedSigner?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedSigner?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command value={selectedSigner?.email}>
|
||||
<CommandInput />
|
||||
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
<Trans>No recipient matching this description was found.</Trans>
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay.map(([role, roleRecipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
||||
</div>
|
||||
|
||||
{roleRecipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
<Trans>No recipients with this role</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roleRecipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn(
|
||||
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
|
||||
getSignerColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === recipient.id),
|
||||
0,
|
||||
),
|
||||
).default.comboxBoxItem,
|
||||
{
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
},
|
||||
)}
|
||||
onSelect={() => {
|
||||
setSelectedSigner(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedSigner,
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && (
|
||||
<span title={recipient.email}>{recipient.email}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center justify-center">
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
aria-hidden={recipient !== selectedSigner}
|
||||
className={cn('h-4 w-4 flex-shrink-0', {
|
||||
'opacity-0': recipient !== selectedSigner,
|
||||
'opacity-100': recipient === selectedSigner,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="ml-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<Trans>
|
||||
This document has already been sent to this recipient. You
|
||||
can no longer edit this recipient.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<RecipientSelector
|
||||
selectedRecipient={selectedSigner}
|
||||
onSelectedRecipientChange={setSelectedSigner}
|
||||
recipients={recipients}
|
||||
className="mb-12 mt-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
|
||||
128
packages/ui/primitives/field-selector.tsx
Normal file
128
packages/ui/primitives/field-selector.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { FieldType } from '@prisma/client';
|
||||
import {
|
||||
CalendarDays,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
Contact,
|
||||
Disc,
|
||||
Hash,
|
||||
Mail,
|
||||
Type,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Card, CardContent } from './card';
|
||||
|
||||
export interface FieldSelectorProps {
|
||||
className?: string;
|
||||
selectedField: FieldType | null;
|
||||
onSelectedFieldChange: (fieldType: FieldType) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const FieldSelector = ({
|
||||
className,
|
||||
selectedField,
|
||||
onSelectedFieldChange,
|
||||
disabled = false,
|
||||
}: FieldSelectorProps) => {
|
||||
const fieldTypes = [
|
||||
{
|
||||
type: FieldType.SIGNATURE,
|
||||
label: 'Signature',
|
||||
icon: null,
|
||||
},
|
||||
{
|
||||
type: FieldType.INITIALS,
|
||||
label: 'Initials',
|
||||
icon: Contact,
|
||||
},
|
||||
{
|
||||
type: FieldType.EMAIL,
|
||||
label: 'Email',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
type: FieldType.NAME,
|
||||
label: 'Name',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
type: FieldType.DATE,
|
||||
label: 'Date',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
type: FieldType.TEXT,
|
||||
label: 'Text',
|
||||
icon: Type,
|
||||
},
|
||||
{
|
||||
type: FieldType.NUMBER,
|
||||
label: 'Number',
|
||||
icon: Hash,
|
||||
},
|
||||
{
|
||||
type: FieldType.RADIO,
|
||||
label: 'Radio',
|
||||
icon: Disc,
|
||||
},
|
||||
{
|
||||
type: FieldType.CHECKBOX,
|
||||
label: 'Checkbox',
|
||||
icon: CheckSquare,
|
||||
},
|
||||
{
|
||||
type: FieldType.DROPDOWN,
|
||||
label: 'Dropdown',
|
||||
icon: ChevronDown,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{fieldTypes.map((field) => {
|
||||
const Icon = field.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={field.type}
|
||||
type="button"
|
||||
className="group w-full"
|
||||
onPointerDown={() => onSelectedFieldChange(field.type)}
|
||||
disabled={disabled}
|
||||
data-selected={selectedField === field.type ? true : undefined}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer items-center justify-center group-disabled:opacity-50',
|
||||
{
|
||||
'border-primary': selectedField === field.type,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<CardContent className="relative flex items-center justify-center gap-x-2 px-6 py-4">
|
||||
{Icon && <Icon className="text-muted-foreground h-4 w-4" />}
|
||||
<span
|
||||
className={cn(
|
||||
'text-muted-foreground group-data-[selected]:text-foreground text-sm',
|
||||
field.type === FieldType.SIGNATURE && 'invisible',
|
||||
)}
|
||||
>
|
||||
{field.label}
|
||||
</span>
|
||||
|
||||
{field.type === FieldType.SIGNATURE && (
|
||||
<div className="text-muted-foreground font-signature absolute inset-0 flex items-center justify-center text-lg">
|
||||
Signature
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
195
packages/ui/primitives/recipient-selector.tsx
Normal file
195
packages/ui/primitives/recipient-selector.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Recipient } from '@prisma/client';
|
||||
import { RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { Check, ChevronsUpDown, Info } from 'lucide-react';
|
||||
import { sortBy } from 'remeda';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
|
||||
import { getSignerColorStyles } from '../lib/signer-colors';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from './command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
|
||||
|
||||
export interface RecipientSelectorProps {
|
||||
className?: string;
|
||||
selectedRecipient: Recipient | null;
|
||||
onSelectedRecipientChange: (recipient: Recipient) => void;
|
||||
recipients: Recipient[];
|
||||
}
|
||||
|
||||
export const RecipientSelector = ({
|
||||
className,
|
||||
selectedRecipient,
|
||||
onSelectedRecipientChange,
|
||||
recipients,
|
||||
}: RecipientSelectorProps) => {
|
||||
const { _ } = useLingui();
|
||||
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
|
||||
|
||||
const recipientsByRole = useCallback(() => {
|
||||
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
|
||||
CC: [],
|
||||
VIEWER: [],
|
||||
SIGNER: [],
|
||||
APPROVER: [],
|
||||
ASSISTANT: [],
|
||||
};
|
||||
|
||||
recipients.forEach((recipient) => {
|
||||
recipientsByRole[recipient.role].push(recipient);
|
||||
});
|
||||
|
||||
return recipientsByRole;
|
||||
}, [recipients]);
|
||||
|
||||
const recipientsByRoleToDisplay = useCallback(() => {
|
||||
return Object.entries(recipientsByRole())
|
||||
.filter(
|
||||
([role]) =>
|
||||
role !== RecipientRole.CC &&
|
||||
role !== RecipientRole.VIEWER &&
|
||||
role !== RecipientRole.ASSISTANT,
|
||||
)
|
||||
.map(
|
||||
([role, roleRecipients]) =>
|
||||
[
|
||||
role,
|
||||
sortBy(
|
||||
roleRecipients,
|
||||
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
|
||||
[(r) => r.id, 'asc'],
|
||||
),
|
||||
] as [RecipientRole, Recipient[]],
|
||||
);
|
||||
}, [recipientsByRole]);
|
||||
|
||||
return (
|
||||
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
|
||||
getSignerColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === selectedRecipient?.id),
|
||||
0,
|
||||
),
|
||||
).default.base,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selectedRecipient?.email && (
|
||||
<span className="flex-1 truncate text-left">
|
||||
{selectedRecipient?.name} ({selectedRecipient?.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!selectedRecipient?.email && (
|
||||
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
|
||||
)}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command value={selectedRecipient?.email}>
|
||||
<CommandInput />
|
||||
|
||||
<CommandEmpty>
|
||||
<span className="text-muted-foreground inline-block px-4">
|
||||
<Trans>No recipient matching this description was found.</Trans>
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
|
||||
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
|
||||
<CommandGroup key={roleIndex}>
|
||||
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
|
||||
</div>
|
||||
|
||||
{roleRecipients.length === 0 && (
|
||||
<div
|
||||
key={`${role}-empty`}
|
||||
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
|
||||
>
|
||||
<Trans>No recipients with this role</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{roleRecipients.map((recipient) => (
|
||||
<CommandItem
|
||||
key={recipient.id}
|
||||
className={cn(
|
||||
'px-2 last:mb-1 [&:not(:first-child)]:mt-1',
|
||||
getSignerColorStyles(
|
||||
Math.max(
|
||||
recipients.findIndex((r) => r.id === recipient.id),
|
||||
0,
|
||||
),
|
||||
).default.comboxBoxItem,
|
||||
{
|
||||
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
|
||||
},
|
||||
)}
|
||||
onSelect={() => {
|
||||
onSelectedRecipientChange(recipient);
|
||||
setShowRecipientsSelector(false);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn('text-foreground/70 truncate', {
|
||||
'text-foreground/80': recipient === selectedRecipient,
|
||||
})}
|
||||
>
|
||||
{recipient.name && (
|
||||
<span title={`${recipient.name} (${recipient.email})`}>
|
||||
{recipient.name} ({recipient.email})
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center justify-center">
|
||||
{recipient.sendStatus !== SendStatus.SENT ? (
|
||||
<Check
|
||||
aria-hidden={recipient !== selectedRecipient}
|
||||
className={cn('h-4 w-4 flex-shrink-0', {
|
||||
'opacity-0': recipient !== selectedRecipient,
|
||||
'opacity-100': recipient === selectedRecipient,
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="ml-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-muted-foreground max-w-xs">
|
||||
<Trans>
|
||||
This document has already been sent to this recipient. You can no longer
|
||||
edit this recipient.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user