Compare commits
45 Commits
v1.5.6-rc.
...
fix/render
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05197993fa | ||
|
|
04cd5b58c2 | ||
|
|
f7e9d1b3cb | ||
|
|
ca077dd3a6 | ||
|
|
8e9287a7c1 | ||
|
|
93e816a5b4 | ||
|
|
d3734ff344 | ||
|
|
0c18f27b3f | ||
|
|
b394e99f7a | ||
|
|
c21e30d689 | ||
|
|
9b92e38c52 | ||
|
|
ac41086e1a | ||
|
|
94cf412f29 | ||
|
|
6650a1d72e | ||
|
|
82848e3d2e | ||
|
|
22b8c2044b | ||
|
|
ef5d267e96 | ||
|
|
518ddea081 | ||
|
|
805758f716 | ||
|
|
04ebb26a0b | ||
|
|
9cb80aa0bc | ||
|
|
0985206088 | ||
|
|
aadb22cdbf | ||
|
|
98672560ca | ||
|
|
6f6ed05569 | ||
|
|
5e3f55c616 | ||
|
|
55d8afe870 | ||
|
|
6df525b670 | ||
|
|
db9e605031 | ||
|
|
bde0f5893f | ||
|
|
6b5750c7bf | ||
|
|
917c83fc5f | ||
|
|
e82e402540 | ||
|
|
c98c1b9467 | ||
|
|
870de02efa | ||
|
|
a58a117056 | ||
|
|
918e9ddc0b | ||
|
|
94eee8b913 | ||
|
|
345c4b8b14 | ||
|
|
897f0dabde | ||
|
|
d5867ae8de | ||
|
|
5391dd91b0 | ||
|
|
4855882ae6 | ||
|
|
c08768a330 | ||
|
|
37e9db6626 |
2
apps/marketing/public/pdf.worker.min.js
vendored
2
apps/marketing/public/pdf.worker.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -248,6 +248,7 @@ export const SinglePlayerClient = () => {
|
||||
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||
fields={fields}
|
||||
onSubmit={onFieldsSubmit}
|
||||
canGoBack={true}
|
||||
isDocumentPdfLoaded={true}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||
|
||||
@@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
|
||||
<FormLabel required>2FA token</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
@@ -25,6 +26,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
const MotionLink = motion(Link);
|
||||
|
||||
export type MenuSwitcherProps = {
|
||||
user: User;
|
||||
teams: GetTeamsResponse;
|
||||
@@ -170,18 +173,43 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||
{teams.map((team) => (
|
||||
<DropdownMenuItem asChild key={team.id}>
|
||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
||||
<MotionLink
|
||||
initial="initial"
|
||||
animate="initial"
|
||||
whileHover="animate"
|
||||
href={formatRedirectUrlOnSwitch(team.url)}
|
||||
>
|
||||
<AvatarWithText
|
||||
avatarFallback={formatAvatarFallback(team.name)}
|
||||
primaryText={team.name}
|
||||
secondaryText={formatSecondaryAvatarText(team)}
|
||||
secondaryText={
|
||||
<div className="relative">
|
||||
<motion.span
|
||||
className="overflow-hidden"
|
||||
variants={{
|
||||
initial: { opacity: 1, translateY: 0 },
|
||||
animate: { opacity: 0, translateY: '100%' },
|
||||
}}
|
||||
>
|
||||
{formatSecondaryAvatarText(team)}
|
||||
</motion.span>
|
||||
|
||||
<motion.span
|
||||
className="absolute inset-0"
|
||||
variants={{
|
||||
initial: { opacity: 0, translateY: '100%' },
|
||||
animate: { opacity: 1, translateY: 0 },
|
||||
}}
|
||||
>{`/t/${team.url}`}</motion.span>
|
||||
</div>
|
||||
}
|
||||
rightSideComponent={
|
||||
isPathTeamUrl(team.url) && (
|
||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
</MotionLink>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZDisable2FAForm = z.object({
|
||||
@@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
@@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" value={field.value ?? ''} />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { RecoveryCodeList } from './recovery-code-list';
|
||||
|
||||
@@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Token" />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { PasswordInput } from '@documenso/ui/primitives/password-input';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||
@@ -372,9 +373,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
||||
name="totpCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Authentication Token</FormLabel>
|
||||
<FormLabel>Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -109,6 +109,7 @@
|
||||
"cookie-es": "^1.0.0",
|
||||
"formidable": "^2.1.1",
|
||||
"framer-motion": "^10.12.8",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.279.0",
|
||||
"luxon": "^3.4.0",
|
||||
"micro": "^10.0.1",
|
||||
@@ -13767,6 +13768,15 @@
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
||||
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz",
|
||||
"integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createNextRoute } from '@ts-rest/next';
|
||||
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||
@@ -76,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
status: 200,
|
||||
body: {
|
||||
...document,
|
||||
recipients,
|
||||
recipients: recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -258,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@@ -349,6 +355,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@@ -428,6 +436,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
email: recipient.email,
|
||||
token: recipient.token,
|
||||
role: recipient.role,
|
||||
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
@@ -435,6 +445,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
|
||||
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||
const { id } = args.params;
|
||||
const { sendEmail = true } = args.body ?? {};
|
||||
|
||||
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
|
||||
|
||||
@@ -490,10 +501,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
// });
|
||||
// }
|
||||
|
||||
await sendDocument({
|
||||
const { Recipient: recipients, ...sentDocument } = await sendDocument({
|
||||
documentId: Number(id),
|
||||
userId: user.id,
|
||||
teamId: team?.id,
|
||||
sendEmail,
|
||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||
});
|
||||
|
||||
@@ -501,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
status: 200,
|
||||
body: {
|
||||
message: 'Document sent for signing successfully',
|
||||
...sentDocument,
|
||||
recipients: recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -585,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
body: {
|
||||
...newRecipient,
|
||||
documentId: Number(documentId),
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -650,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
body: {
|
||||
...updatedRecipient,
|
||||
documentId: Number(documentId),
|
||||
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -703,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
||||
body: {
|
||||
...deletedRecipient,
|
||||
documentId: Number(documentId),
|
||||
signingUrl: '',
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -45,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer<
|
||||
|
||||
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
||||
|
||||
export const ZSendDocumentForSigningMutationSchema = null;
|
||||
export const ZSendDocumentForSigningMutationSchema = z
|
||||
.object({
|
||||
sendEmail: z.boolean().optional().default(true),
|
||||
})
|
||||
.or(z.literal('').transform(() => ({ sendEmail: true })));
|
||||
|
||||
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||
|
||||
@@ -89,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
|
||||
recipients: z.array(
|
||||
z.object({
|
||||
recipientId: z.number(),
|
||||
name: z.string(),
|
||||
email: z.string().email().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
|
||||
signingUrl: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -134,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
||||
|
||||
signingUrl: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -187,6 +197,8 @@ export const ZGenerateDocumentFromTemplateMutationResponseSchema = z.object({
|
||||
email: z.string().email().min(1),
|
||||
token: z.string(),
|
||||
role: z.nativeEnum(RecipientRole),
|
||||
|
||||
signingUrl: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
@@ -229,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
|
||||
readStatus: z.nativeEnum(ReadStatus),
|
||||
signingStatus: z.nativeEnum(SigningStatus),
|
||||
sendStatus: z.nativeEnum(SendStatus),
|
||||
|
||||
signingUrl: z.string(),
|
||||
});
|
||||
|
||||
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
||||
@@ -279,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({
|
||||
|
||||
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
||||
|
||||
export const ZSuccessfulSigningResponseSchema = z.object({
|
||||
export const ZSuccessfulSigningResponseSchema = z
|
||||
.object({
|
||||
message: z.string(),
|
||||
});
|
||||
})
|
||||
.and(ZSuccessfulGetDocumentResponseSchema);
|
||||
|
||||
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import {
|
||||
seedBlankDocument,
|
||||
seedPendingDocumentWithFullFields,
|
||||
} from '@documenso/prisma/seed/documents';
|
||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
@@ -192,6 +196,102 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipients with different roles', async ({
|
||||
page,
|
||||
}) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
// Set title
|
||||
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
|
||||
await page.getByLabel('Title').fill('Test Title');
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add signers
|
||||
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||
|
||||
// Add 2 signers.
|
||||
await page.getByPlaceholder('Email').fill('user1@example.com');
|
||||
await page.getByPlaceholder('Name').fill('User 1');
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).fill('user2@example.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(1).fill('User 2');
|
||||
await page.locator('button[role="combobox"]').nth(1).click();
|
||||
await page.getByLabel('Receives copy').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(1).fill('user3@example.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(2).fill('User 3');
|
||||
await page.locator('button[role="combobox"]').nth(2).click();
|
||||
await page.getByLabel('Needs to approve').click();
|
||||
await page.getByRole('button', { name: 'Add Signer' }).click();
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email', exact: true }).nth(2).fill('user4@example.com');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).nth(3).fill('User 4');
|
||||
await page.locator('button[role="combobox"]').nth(3).click();
|
||||
await page.getByLabel('Needs to view').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add fields
|
||||
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'User 1 Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByText('User 1 (user1@example.com)').click();
|
||||
await page.getByText('User 3 (user3@example.com)').click();
|
||||
|
||||
await page.getByRole('button', { name: 'User 3 Signature' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 100,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Email Email' }).click();
|
||||
await page.locator('canvas').click({
|
||||
position: {
|
||||
x: 500,
|
||||
y: 200,
|
||||
},
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
|
||||
// Add subject and send
|
||||
await expect(page.getByRole('heading', { name: 'Add Subject' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Send' }).click();
|
||||
|
||||
await page.waitForURL('/documents');
|
||||
|
||||
// Assert document was created
|
||||
await expect(page.getByRole('link', { name: 'Test Title' })).toBeVisible();
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const document = await seedBlankDocument(user);
|
||||
@@ -234,6 +334,7 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
||||
await page.getByRole('link', { name: documentTitle }).click();
|
||||
await page.waitForURL(/\/documents\/\d+/);
|
||||
|
||||
// Start signing process
|
||||
const url = page.url().split('/');
|
||||
const documentId = url[url.length - 1];
|
||||
|
||||
@@ -263,6 +364,63 @@ test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', asyn
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to approve a document', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['user@documenso.com', 'approver@documenso.com'],
|
||||
recipientsCreateOptions: [
|
||||
{
|
||||
email: 'user@documenso.com',
|
||||
role: RecipientRole.SIGNER,
|
||||
},
|
||||
{
|
||||
email: 'approver@documenso.com',
|
||||
role: RecipientRole.APPROVER,
|
||||
},
|
||||
],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const { token, Field, role } = recipient;
|
||||
|
||||
const signUrl = `/sign/${token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: role === RecipientRole.SIGNER ? 'Sign Document' : 'Approve Document',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Add signature.
|
||||
const canvas = page.locator('canvas');
|
||||
const box = await canvas.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + box.width / 4, box.y + box.height / 4);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
for (const field of Field) {
|
||||
await page.locator(`#field-${field.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: role === RecipientRole.SIGNER ? 'Sign' : 'Approve' })
|
||||
.click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
}
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -333,3 +491,46 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
test('[DOCUMENT_FLOW]: should be able to sign a document with custom date', async ({ page }) => {
|
||||
const user = await seedUser();
|
||||
const customDate = DateTime.local().toFormat('yyyy-MM-dd hh:mm a');
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['user1@example.com'],
|
||||
fields: [FieldType.DATE],
|
||||
});
|
||||
|
||||
const { token, Field } = recipients[0];
|
||||
const [recipientField] = Field;
|
||||
|
||||
await page.goto(`/sign/${token}`);
|
||||
await page.waitForURL(`/sign/${token}`);
|
||||
|
||||
await page.locator(`#field-${recipientField.id}`).getByRole('button').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await expect(page.getByRole('dialog').getByText('Complete Signing').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${token}/complete`);
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
|
||||
const field = await prisma.field.findFirst({
|
||||
where: {
|
||||
Recipient: {
|
||||
email: 'user1@example.com',
|
||||
},
|
||||
documentId: Number(document.id),
|
||||
},
|
||||
});
|
||||
|
||||
expect(field?.customText).toBe(customDate);
|
||||
|
||||
// Check if document has been signed
|
||||
const { status: completedStatus } = await getDocumentByToken(token);
|
||||
expect(completedStatus).toBe(DocumentStatus.COMPLETED);
|
||||
|
||||
await unseedUser(user.id);
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export type SendDocumentOptions = {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
sendEmail?: boolean;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
@@ -35,6 +36,7 @@ export const sendDocument = async ({
|
||||
documentId,
|
||||
userId,
|
||||
teamId,
|
||||
sendEmail = true,
|
||||
requestMetadata,
|
||||
}: SendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
@@ -120,6 +122,7 @@ export const sendDocument = async ({
|
||||
Object.assign(document, result);
|
||||
}
|
||||
|
||||
if (sendEmail) {
|
||||
await Promise.all(
|
||||
document.Recipient.map(async (recipient) => {
|
||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||
@@ -151,7 +154,9 @@ export const sendDocument = async ({
|
||||
assetBaseUrl,
|
||||
signDocumentLink,
|
||||
customBody: renderCustomEmailTemplate(
|
||||
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
|
||||
selfSigner && !customEmail?.message
|
||||
? selfSignerCustomEmail
|
||||
: customEmail?.message || '',
|
||||
customEmailTemplate,
|
||||
),
|
||||
role: recipient.role,
|
||||
@@ -212,6 +217,7 @@ export const sendDocument = async ({
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const allRecipientsHaveNoActionToTake = document.Recipient.every(
|
||||
(recipient) => recipient.role === RecipientRole.CC,
|
||||
|
||||
@@ -124,10 +124,15 @@ module.exports = {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 },
|
||||
},
|
||||
'caret-blink': {
|
||||
'0%,70%,100%': { opacity: '1' },
|
||||
'20%,50%': { opacity: '0' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||
},
|
||||
screens: {
|
||||
'3xl': '1920px',
|
||||
|
||||
@@ -53,6 +53,7 @@ export type AddFieldsFormProps = {
|
||||
recipients: Recipient[];
|
||||
fields: Field[];
|
||||
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
||||
canGoBack?: boolean;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
};
|
||||
|
||||
@@ -62,10 +63,13 @@ export const AddFieldsFormPartial = ({
|
||||
recipients,
|
||||
fields,
|
||||
onSubmit,
|
||||
canGoBack = false,
|
||||
isDocumentPdfLoaded,
|
||||
}: AddFieldsFormProps) => {
|
||||
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
const canRenderBackButtonAsRemove =
|
||||
currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack;
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -595,7 +599,9 @@ export const AddFieldsFormPartial = ({
|
||||
onGoBackClick={() => {
|
||||
previousStep();
|
||||
remove();
|
||||
documentFlow.onBackStep?.();
|
||||
}}
|
||||
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
|
||||
onGoNextClick={() => void onFormSubmit()}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
|
||||
@@ -105,10 +105,14 @@ export const AddSignersFormPartial = ({
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
||||
|
||||
const {
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
control,
|
||||
watch,
|
||||
} = form;
|
||||
|
||||
const watchedSigners = watch('signers');
|
||||
|
||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||
|
||||
const {
|
||||
@@ -120,6 +124,11 @@ export const AddSignersFormPartial = ({
|
||||
name: 'signers',
|
||||
});
|
||||
|
||||
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
|
||||
const isUserAlreadyARecipient = watchedSigners.some(
|
||||
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
|
||||
);
|
||||
|
||||
const hasBeenSentToRecipientId = (id?: number) => {
|
||||
if (!id) {
|
||||
return false;
|
||||
@@ -133,16 +142,6 @@ export const AddSignersFormPartial = ({
|
||||
);
|
||||
};
|
||||
|
||||
const onAddSelfSigner = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onAddSigner = () => {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
@@ -169,6 +168,21 @@ export const AddSignersFormPartial = ({
|
||||
removeSigner(index);
|
||||
};
|
||||
|
||||
const onAddSelfSigner = () => {
|
||||
if (emptySignerIndex !== -1) {
|
||||
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '');
|
||||
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '');
|
||||
} else {
|
||||
appendSigner({
|
||||
formId: nanoid(12),
|
||||
name: user?.name ?? '',
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
||||
onAddSigner();
|
||||
@@ -218,11 +232,7 @@ export const AddSignersFormPartial = ({
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
{...field}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -248,11 +258,7 @@ export const AddSignersFormPartial = ({
|
||||
<Input
|
||||
placeholder="Name"
|
||||
{...field}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
||||
signers[index].email === user?.email
|
||||
}
|
||||
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -335,14 +341,12 @@ export const AddSignersFormPartial = ({
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
Add Signer
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
form.getValues('signers').some((signer) => signer.email === user?.email)
|
||||
}
|
||||
disabled={isSubmitting || isUserAlreadyARecipient}
|
||||
onClick={() => onAddSelfSigner()}
|
||||
>
|
||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||
|
||||
76
packages/ui/primitives/pin-input.tsx
Normal file
76
packages/ui/primitives/pin-input.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Minus } from 'lucide-react';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const PinInput = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
PinInput.displayName = 'PinInput';
|
||||
|
||||
const PinInputGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
|
||||
PinInputGroup.displayName = 'PinInputGroup';
|
||||
|
||||
const PinInputSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const context = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = context.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-input relative flex h-10 w-10 items-center justify-center border-y border-r font-mono shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
|
||||
isActive && 'ring-ring z-10 ring-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PinInputSlot.displayName = 'PinInputSlot';
|
||||
|
||||
const PinInputSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus className="h-5 w-5" />
|
||||
</div>
|
||||
));
|
||||
|
||||
PinInputSeparator.displayName = 'PinInputSeparator';
|
||||
|
||||
export { PinInput, PinInputGroup, PinInputSlot, PinInputSeparator };
|
||||
62
render.yaml
62
render.yaml
@@ -1,11 +1,11 @@
|
||||
services:
|
||||
- type: web
|
||||
runtime: node
|
||||
name: documenso-app
|
||||
env: node
|
||||
plan: free
|
||||
buildCommand: npm i && npm run build:web
|
||||
startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npm run start
|
||||
healthCheckPath: /api/trpc/health
|
||||
startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npx turbo run start --filter=@documenso/web
|
||||
healthCheckPath: /api/health
|
||||
|
||||
envVars:
|
||||
# Node Version
|
||||
@@ -98,6 +98,62 @@ services:
|
||||
- key: NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY
|
||||
sync: false
|
||||
|
||||
# Crypto
|
||||
- key: NEXT_PRIVATE_ENCRYPTION_KEY
|
||||
generateValue: true
|
||||
- key: NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY
|
||||
generateValue: true
|
||||
|
||||
# Auth Optional
|
||||
- key: NEXT_PRIVATE_GOOGLE_CLIENT_ID
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_GOOGLE_CLIENT_SECRET
|
||||
sync: false
|
||||
|
||||
# Signing
|
||||
- key: NEXT_PRIVATE_SIGNING_TRANSPORT
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SIGNING_PASSPHRASE
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_KEY_PATH
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_PATH
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SIGNING_GCLOUD_HSM_PUBLIC_CRT_FILE_CONTENTS
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SIGNING_GCLOUD_APPLICATION_CREDENTIALS_CONTENTS
|
||||
sync: false
|
||||
|
||||
# SMTP Optional
|
||||
- key: NEXT_PRIVATE_SMTP_APIKEY_USER
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SMTP_APIKEY
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_SMTP_SECURE
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_RESEND_API_KEY
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_MAILCHANNELS_API_KEY
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_MAILCHANNELS_ENDPOINT
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR
|
||||
sync: false
|
||||
- key: NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY
|
||||
sync: false
|
||||
- key: NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT
|
||||
sync: false
|
||||
|
||||
# Features Optional
|
||||
- key: NEXT_PUBLIC_DISABLE_SIGNUP
|
||||
sync: false
|
||||
|
||||
databases:
|
||||
- name: documenso-db
|
||||
plan: free
|
||||
|
||||
Reference in New Issue
Block a user