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] : []}
|
recipients={uploadedFile ? [placeholderRecipient] : []}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onSubmit={onFieldsSubmit}
|
onSubmit={onFieldsSubmit}
|
||||||
|
canGoBack={true}
|
||||||
isDocumentPdfLoaded={true}
|
isDocumentPdfLoaded={true}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"cookie-es": "^1.0.0",
|
"cookie-es": "^1.0.0",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} 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';
|
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
|
||||||
|
|
||||||
@@ -138,7 +138,15 @@ export const DocumentActionAuth2FA = ({
|
|||||||
<FormLabel required>2FA token</FormLabel>
|
<FormLabel required>2FA token</FormLabel>
|
||||||
|
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
import { CheckCircle2, ChevronsUpDown, Plus, Settings2 } from 'lucide-react';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
|
|
||||||
|
const MotionLink = motion(Link);
|
||||||
|
|
||||||
export type MenuSwitcherProps = {
|
export type MenuSwitcherProps = {
|
||||||
user: User;
|
user: User;
|
||||||
teams: GetTeamsResponse;
|
teams: GetTeamsResponse;
|
||||||
@@ -170,18 +173,43 @@ export const MenuSwitcher = ({ user, teams: initialTeamsData }: MenuSwitcherProp
|
|||||||
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
<div className="custom-scrollbar max-h-[40vh] overflow-auto">
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<DropdownMenuItem asChild key={team.id}>
|
<DropdownMenuItem asChild key={team.id}>
|
||||||
<Link href={formatRedirectUrlOnSwitch(team.url)}>
|
<MotionLink
|
||||||
|
initial="initial"
|
||||||
|
animate="initial"
|
||||||
|
whileHover="animate"
|
||||||
|
href={formatRedirectUrlOnSwitch(team.url)}
|
||||||
|
>
|
||||||
<AvatarWithText
|
<AvatarWithText
|
||||||
avatarFallback={formatAvatarFallback(team.name)}
|
avatarFallback={formatAvatarFallback(team.name)}
|
||||||
primaryText={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={
|
rightSideComponent={
|
||||||
isPathTeamUrl(team.url) && (
|
isPathTeamUrl(team.url) && (
|
||||||
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
<CheckCircle2 className="ml-auto fill-black text-white dark:fill-white dark:text-black" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</MotionLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
export const ZDisable2FAForm = z.object({
|
export const ZDisable2FAForm = z.object({
|
||||||
@@ -107,7 +107,15 @@ export const DisableAuthenticatorAppDialog = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} 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 { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
import { RecoveryCodeList } from './recovery-code-list';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
@@ -212,7 +212,15 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
FormItem,
|
FormItem,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@documenso/ui/primitives/form/form';
|
} 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';
|
import { RecoveryCodeList } from './recovery-code-list';
|
||||||
|
|
||||||
@@ -115,7 +115,15 @@ export const ViewRecoveryCodesDialog = () => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import { PasswordInput } from '@documenso/ui/primitives/password-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';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||||
@@ -372,9 +373,17 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
|
|||||||
name="totpCode"
|
name="totpCode"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Authentication Token</FormLabel>
|
<FormLabel>Token</FormLabel>
|
||||||
<FormControl>
|
<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>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -109,6 +109,7 @@
|
|||||||
"cookie-es": "^1.0.0",
|
"cookie-es": "^1.0.0",
|
||||||
"formidable": "^2.1.1",
|
"formidable": "^2.1.1",
|
||||||
"framer-motion": "^10.12.8",
|
"framer-motion": "^10.12.8",
|
||||||
|
"input-otp": "^1.2.4",
|
||||||
"lucide-react": "^0.279.0",
|
"lucide-react": "^0.279.0",
|
||||||
"luxon": "^3.4.0",
|
"luxon": "^3.4.0",
|
||||||
"micro": "^10.0.1",
|
"micro": "^10.0.1",
|
||||||
@@ -13767,6 +13768,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz",
|
||||||
"integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="
|
"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": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createNextRoute } from '@ts-rest/next';
|
import { createNextRoute } from '@ts-rest/next';
|
||||||
|
|
||||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
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 { AppError } from '@documenso/lib/errors/app-error';
|
||||||
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
|
||||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
@@ -76,7 +77,10 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
...document,
|
...document,
|
||||||
recipients,
|
recipients: recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -258,6 +262,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -349,6 +355,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
role: recipient.role,
|
role: recipient.role,
|
||||||
|
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -428,6 +436,8 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
token: recipient.token,
|
token: recipient.token,
|
||||||
role: recipient.role,
|
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) => {
|
sendDocument: authenticatedMiddleware(async (args, user, team) => {
|
||||||
const { id } = args.params;
|
const { id } = args.params;
|
||||||
|
const { sendEmail = true } = args.body ?? {};
|
||||||
|
|
||||||
const document = await getDocumentById({ id: Number(id), userId: user.id, teamId: team?.id });
|
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),
|
documentId: Number(id),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: team?.id,
|
teamId: team?.id,
|
||||||
|
sendEmail,
|
||||||
requestMetadata: extractNextApiRequestMetadata(args.req),
|
requestMetadata: extractNextApiRequestMetadata(args.req),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -501,6 +513,11 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
message: 'Document sent for signing successfully',
|
message: 'Document sent for signing successfully',
|
||||||
|
...sentDocument,
|
||||||
|
recipients: recipients.map((recipient) => ({
|
||||||
|
...recipient,
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -585,6 +602,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...newRecipient,
|
...newRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${newRecipient.token}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -650,6 +668,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...updatedRecipient,
|
...updatedRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${updatedRecipient.token}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -703,6 +722,7 @@ export const ApiContractV1Implementation = createNextRoute(ApiContractV1, {
|
|||||||
body: {
|
body: {
|
||||||
...deletedRecipient,
|
...deletedRecipient,
|
||||||
documentId: Number(documentId),
|
documentId: Number(documentId),
|
||||||
|
signingUrl: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ export type TSuccessfulGetDocumentResponseSchema = z.infer<
|
|||||||
|
|
||||||
export type TSuccessfulDocumentResponseSchema = z.infer<typeof ZSuccessfulDocumentResponseSchema>;
|
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;
|
export type TSendDocumentForSigningMutationSchema = typeof ZSendDocumentForSigningMutationSchema;
|
||||||
|
|
||||||
@@ -89,8 +93,12 @@ export const ZCreateDocumentMutationResponseSchema = z.object({
|
|||||||
recipients: z.array(
|
recipients: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
recipientId: z.number(),
|
recipientId: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email().min(1),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -134,6 +142,8 @@ export const ZCreateDocumentFromTemplateMutationResponseSchema = z.object({
|
|||||||
email: z.string().email().min(1),
|
email: z.string().email().min(1),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole).optional().default(RecipientRole.SIGNER),
|
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),
|
email: z.string().email().min(1),
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
role: z.nativeEnum(RecipientRole),
|
role: z.nativeEnum(RecipientRole),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -229,6 +241,8 @@ export const ZSuccessfulRecipientResponseSchema = z.object({
|
|||||||
readStatus: z.nativeEnum(ReadStatus),
|
readStatus: z.nativeEnum(ReadStatus),
|
||||||
signingStatus: z.nativeEnum(SigningStatus),
|
signingStatus: z.nativeEnum(SigningStatus),
|
||||||
sendStatus: z.nativeEnum(SendStatus),
|
sendStatus: z.nativeEnum(SendStatus),
|
||||||
|
|
||||||
|
signingUrl: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
export type TSuccessfulRecipientResponseSchema = z.infer<typeof ZSuccessfulRecipientResponseSchema>;
|
||||||
@@ -279,9 +293,11 @@ export const ZSuccessfulResponseSchema = z.object({
|
|||||||
|
|
||||||
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
export type TSuccessfulResponseSchema = z.infer<typeof ZSuccessfulResponseSchema>;
|
||||||
|
|
||||||
export const ZSuccessfulSigningResponseSchema = z.object({
|
export const ZSuccessfulSigningResponseSchema = z
|
||||||
message: z.string(),
|
.object({
|
||||||
});
|
message: z.string(),
|
||||||
|
})
|
||||||
|
.and(ZSuccessfulGetDocumentResponseSchema);
|
||||||
|
|
||||||
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
export type TSuccessfulSigningResponseSchema = z.infer<typeof ZSuccessfulSigningResponseSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
import { getRecipientByEmail } from '@documenso/lib/server-only/recipient/get-recipient-by-email';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentStatus } from '@documenso/prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
import {
|
||||||
|
seedBlankDocument,
|
||||||
|
seedPendingDocumentWithFullFields,
|
||||||
|
} from '@documenso/prisma/seed/documents';
|
||||||
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
import { seedUser, unseedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
import { apiSignin } from '../fixtures/authentication';
|
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);
|
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 }) => {
|
test('[DOCUMENT_FLOW]: should be able to create, send and sign a document', async ({ page }) => {
|
||||||
const user = await seedUser();
|
const user = await seedUser();
|
||||||
const document = await seedBlankDocument(user);
|
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.getByRole('link', { name: documentTitle }).click();
|
||||||
await page.waitForURL(/\/documents\/\d+/);
|
await page.waitForURL(/\/documents\/\d+/);
|
||||||
|
|
||||||
|
// Start signing process
|
||||||
const url = page.url().split('/');
|
const url = page.url().split('/');
|
||||||
const documentId = url[url.length - 1];
|
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);
|
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 ({
|
test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a document and redirect to redirect url', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -333,3 +491,46 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
|
|||||||
|
|
||||||
await unseedUser(user.id);
|
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;
|
documentId: number;
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId?: number;
|
||||||
|
sendEmail?: boolean;
|
||||||
requestMetadata?: RequestMetadata;
|
requestMetadata?: RequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export const sendDocument = async ({
|
|||||||
documentId,
|
documentId,
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
|
sendEmail = true,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: SendDocumentOptions) => {
|
}: SendDocumentOptions) => {
|
||||||
const user = await prisma.user.findFirstOrThrow({
|
const user = await prisma.user.findFirstOrThrow({
|
||||||
@@ -120,98 +122,102 @@ export const sendDocument = async ({
|
|||||||
Object.assign(document, result);
|
Object.assign(document, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
if (sendEmail) {
|
||||||
document.Recipient.map(async (recipient) => {
|
await Promise.all(
|
||||||
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
document.Recipient.map(async (recipient) => {
|
||||||
return;
|
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
const recipientEmailType = RECIPIENT_ROLE_TO_EMAIL_TYPE[recipient.role];
|
||||||
|
|
||||||
const { email, name } = recipient;
|
const { email, name } = recipient;
|
||||||
const selfSigner = email === user.email;
|
const selfSigner = email === user.email;
|
||||||
|
|
||||||
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
const selfSignerCustomEmail = `You have initiated the document ${`"${document.title}"`} that requires you to ${RECIPIENT_ROLES_DESCRIPTION[
|
||||||
recipient.role
|
recipient.role
|
||||||
].actionVerb.toLowerCase()} it.`;
|
].actionVerb.toLowerCase()} it.`;
|
||||||
|
|
||||||
const customEmailTemplate = {
|
const customEmailTemplate = {
|
||||||
'signer.name': name,
|
'signer.name': name,
|
||||||
'signer.email': email,
|
'signer.email': email,
|
||||||
'document.name': document.title,
|
'document.name': document.title,
|
||||||
};
|
};
|
||||||
|
|
||||||
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
|
||||||
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
const signDocumentLink = `${NEXT_PUBLIC_WEBAPP_URL()}/sign/${recipient.token}`;
|
||||||
|
|
||||||
const template = createElement(DocumentInviteEmailTemplate, {
|
const template = createElement(DocumentInviteEmailTemplate, {
|
||||||
documentName: document.title,
|
documentName: document.title,
|
||||||
inviterName: user.name || undefined,
|
inviterName: user.name || undefined,
|
||||||
inviterEmail: user.email,
|
inviterEmail: user.email,
|
||||||
assetBaseUrl,
|
assetBaseUrl,
|
||||||
signDocumentLink,
|
signDocumentLink,
|
||||||
customBody: renderCustomEmailTemplate(
|
customBody: renderCustomEmailTemplate(
|
||||||
selfSigner && !customEmail?.message ? selfSignerCustomEmail : customEmail?.message || '',
|
selfSigner && !customEmail?.message
|
||||||
customEmailTemplate,
|
? selfSignerCustomEmail
|
||||||
),
|
: customEmail?.message || '',
|
||||||
role: recipient.role,
|
customEmailTemplate,
|
||||||
selfSigner,
|
),
|
||||||
});
|
role: recipient.role,
|
||||||
|
selfSigner,
|
||||||
|
});
|
||||||
|
|
||||||
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
|
||||||
|
|
||||||
const emailSubject = selfSigner
|
const emailSubject = selfSigner
|
||||||
? `Please ${actionVerb.toLowerCase()} your document`
|
? `Please ${actionVerb.toLowerCase()} your document`
|
||||||
: `Please ${actionVerb.toLowerCase()} this document`;
|
: `Please ${actionVerb.toLowerCase()} this document`;
|
||||||
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
await mailer.sendMail({
|
await mailer.sendMail({
|
||||||
to: {
|
to: {
|
||||||
address: email,
|
address: email,
|
||||||
name,
|
name,
|
||||||
},
|
|
||||||
from: {
|
|
||||||
name: FROM_NAME,
|
|
||||||
address: FROM_ADDRESS,
|
|
||||||
},
|
|
||||||
subject: customEmail?.subject
|
|
||||||
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
|
||||||
: emailSubject,
|
|
||||||
html: render(template),
|
|
||||||
text: render(template, { plainText: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.recipient.update({
|
|
||||||
where: {
|
|
||||||
id: recipient.id,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
sendStatus: SendStatus.SENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await tx.documentAuditLog.create({
|
|
||||||
data: createDocumentAuditLogData({
|
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
|
||||||
documentId: document.id,
|
|
||||||
user,
|
|
||||||
requestMetadata,
|
|
||||||
data: {
|
|
||||||
emailType: recipientEmailType,
|
|
||||||
recipientEmail: recipient.email,
|
|
||||||
recipientName: recipient.name,
|
|
||||||
recipientRole: recipient.role,
|
|
||||||
recipientId: recipient.id,
|
|
||||||
isResending: false,
|
|
||||||
},
|
},
|
||||||
}),
|
from: {
|
||||||
});
|
name: FROM_NAME,
|
||||||
},
|
address: FROM_ADDRESS,
|
||||||
{ timeout: 30_000 },
|
},
|
||||||
);
|
subject: customEmail?.subject
|
||||||
}),
|
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
|
||||||
);
|
: emailSubject,
|
||||||
|
html: render(template),
|
||||||
|
text: render(template, { plainText: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
sendStatus: SendStatus.SENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.documentAuditLog.create({
|
||||||
|
data: createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
|
documentId: document.id,
|
||||||
|
user,
|
||||||
|
requestMetadata,
|
||||||
|
data: {
|
||||||
|
emailType: recipientEmailType,
|
||||||
|
recipientEmail: recipient.email,
|
||||||
|
recipientName: recipient.name,
|
||||||
|
recipientRole: recipient.role,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
isResending: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const allRecipientsHaveNoActionToTake = document.Recipient.every(
|
const allRecipientsHaveNoActionToTake = document.Recipient.every(
|
||||||
(recipient) => recipient.role === RecipientRole.CC,
|
(recipient) => recipient.role === RecipientRole.CC,
|
||||||
|
|||||||
@@ -124,10 +124,15 @@ module.exports = {
|
|||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
to: { height: 0 },
|
to: { height: 0 },
|
||||||
},
|
},
|
||||||
|
'caret-blink': {
|
||||||
|
'0%,70%,100%': { opacity: '1' },
|
||||||
|
'20%,50%': { opacity: '0' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
'caret-blink': 'caret-blink 1.25s ease-out infinite',
|
||||||
},
|
},
|
||||||
screens: {
|
screens: {
|
||||||
'3xl': '1920px',
|
'3xl': '1920px',
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export type AddFieldsFormProps = {
|
|||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
onSubmit: (_data: TAddFieldsFormSchema) => void;
|
||||||
|
canGoBack?: boolean;
|
||||||
isDocumentPdfLoaded: boolean;
|
isDocumentPdfLoaded: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,10 +63,13 @@ export const AddFieldsFormPartial = ({
|
|||||||
recipients,
|
recipients,
|
||||||
fields,
|
fields,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
canGoBack = false,
|
||||||
isDocumentPdfLoaded,
|
isDocumentPdfLoaded,
|
||||||
}: AddFieldsFormProps) => {
|
}: AddFieldsFormProps) => {
|
||||||
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
|
||||||
const { currentStep, totalSteps, previousStep } = useStep();
|
const { currentStep, totalSteps, previousStep } = useStep();
|
||||||
|
const canRenderBackButtonAsRemove =
|
||||||
|
currentStep === 1 && typeof documentFlow.onBackStep === 'function' && canGoBack;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@@ -595,7 +599,9 @@ export const AddFieldsFormPartial = ({
|
|||||||
onGoBackClick={() => {
|
onGoBackClick={() => {
|
||||||
previousStep();
|
previousStep();
|
||||||
remove();
|
remove();
|
||||||
|
documentFlow.onBackStep?.();
|
||||||
}}
|
}}
|
||||||
|
goBackLabel={canRenderBackButtonAsRemove ? 'Remove' : undefined}
|
||||||
onGoNextClick={() => void onFormSubmit()}
|
onGoNextClick={() => void onFormSubmit()}
|
||||||
/>
|
/>
|
||||||
</DocumentFlowFormContainerFooter>
|
</DocumentFlowFormContainerFooter>
|
||||||
|
|||||||
@@ -105,10 +105,14 @@ export const AddSignersFormPartial = ({
|
|||||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
const [showAdvancedSettings, setShowAdvancedSettings] = useState(alwaysShowAdvancedSettings);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
control,
|
control,
|
||||||
|
watch,
|
||||||
} = form;
|
} = form;
|
||||||
|
|
||||||
|
const watchedSigners = watch('signers');
|
||||||
|
|
||||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -120,6 +124,11 @@ export const AddSignersFormPartial = ({
|
|||||||
name: 'signers',
|
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) => {
|
const hasBeenSentToRecipientId = (id?: number) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return false;
|
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 = () => {
|
const onAddSigner = () => {
|
||||||
appendSigner({
|
appendSigner({
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
@@ -169,6 +168,21 @@ export const AddSignersFormPartial = ({
|
|||||||
removeSigner(index);
|
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>) => {
|
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
if (event.key === 'Enter' && event.target instanceof HTMLInputElement) {
|
||||||
onAddSigner();
|
onAddSigner();
|
||||||
@@ -218,11 +232,7 @@ export const AddSignersFormPartial = ({
|
|||||||
type="email"
|
type="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
{...field}
|
{...field}
|
||||||
disabled={
|
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||||
isSubmitting ||
|
|
||||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
|
||||||
signers[index].email === user?.email
|
|
||||||
}
|
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -248,11 +258,7 @@ export const AddSignersFormPartial = ({
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
{...field}
|
{...field}
|
||||||
disabled={
|
disabled={isSubmitting || hasBeenSentToRecipientId(signer.nativeId)}
|
||||||
isSubmitting ||
|
|
||||||
hasBeenSentToRecipientId(signer.nativeId) ||
|
|
||||||
signers[index].email === user?.email
|
|
||||||
}
|
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -335,14 +341,12 @@ export const AddSignersFormPartial = ({
|
|||||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
||||||
Add Signer
|
Add Signer
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
className="dark:bg-muted dark:hover:bg-muted/80 bg-black/5 hover:bg-black/10"
|
||||||
disabled={
|
disabled={isSubmitting || isUserAlreadyARecipient}
|
||||||
isSubmitting ||
|
|
||||||
form.getValues('signers').some((signer) => signer.email === user?.email)
|
|
||||||
}
|
|
||||||
onClick={() => onAddSelfSigner()}
|
onClick={() => onAddSelfSigner()}
|
||||||
>
|
>
|
||||||
<Plus className="-ml-1 mr-2 h-5 w-5" />
|
<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:
|
services:
|
||||||
- type: web
|
- type: web
|
||||||
|
runtime: node
|
||||||
name: documenso-app
|
name: documenso-app
|
||||||
env: node
|
|
||||||
plan: free
|
plan: free
|
||||||
buildCommand: npm i && npm run build:web
|
buildCommand: npm i && npm run build:web
|
||||||
startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npm run start
|
startCommand: npx prisma migrate deploy --schema packages/prisma/schema.prisma && npx turbo run start --filter=@documenso/web
|
||||||
healthCheckPath: /api/trpc/health
|
healthCheckPath: /api/health
|
||||||
|
|
||||||
envVars:
|
envVars:
|
||||||
# Node Version
|
# Node Version
|
||||||
@@ -98,6 +98,62 @@ services:
|
|||||||
- key: NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY
|
- key: NEXT_PRIVATE_UPLOAD_SECRET_ACCESS_KEY
|
||||||
sync: false
|
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:
|
databases:
|
||||||
- name: documenso-db
|
- name: documenso-db
|
||||||
plan: free
|
plan: free
|
||||||
|
|||||||
Reference in New Issue
Block a user