mirror of
https://github.com/documenso/documenso.git
synced 2025-11-20 11:41:44 +10:00
Compare commits
13 Commits
feat/envel
...
archive/1.
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b5c50ed88 | |||
| e4e9e749e5 | |||
| 37ae6a86fd | |||
| 88836404d1 | |||
| 2eebc0e439 | |||
| 4a3859ec60 | |||
| 49b792503f | |||
| c3dc76b1b4 | |||
| daab8461c7 | |||
| 1ffc4bd703 | |||
| f15c0778b5 | |||
| 06cb8b1f23 | |||
| 7f09ba72f4 |
@ -13,6 +13,10 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
|
|||||||
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
# Find documentation on setting up Microsoft OAuth here:
|
||||||
|
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
|
||||||
|
|
||||||
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
|
||||||
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
NEXT_PRIVATE_OIDC_CLIENT_ID=""
|
||||||
|
|||||||
@ -27,3 +27,33 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
|
|||||||
```
|
```
|
||||||
|
|
||||||
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
|
||||||
|
|
||||||
|
## Microsoft OAuth (Azure AD)
|
||||||
|
|
||||||
|
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
|
||||||
|
|
||||||
|
### Create and configure a new Azure AD application
|
||||||
|
|
||||||
|
1. Go to the [Azure Portal](https://portal.azure.com/)
|
||||||
|
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
|
||||||
|
3. In the left sidebar, click **App registrations**
|
||||||
|
4. Click **New registration**
|
||||||
|
5. Enter a name for your application (e.g., "Documenso")
|
||||||
|
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
|
||||||
|
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
|
||||||
|
8. Click **Register**
|
||||||
|
|
||||||
|
### Configure the application
|
||||||
|
|
||||||
|
1. After registration, you'll be taken to the app's overview page
|
||||||
|
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
|
||||||
|
3. In the left sidebar, click **Certificates & secrets**
|
||||||
|
4. Under **Client secrets**, click **New client secret**
|
||||||
|
5. Add a description and select an expiration period
|
||||||
|
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
|
||||||
|
7. In the Documenso environment variables, set the following:
|
||||||
|
|
||||||
|
```
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
|
||||||
|
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
|
||||||
|
```
|
||||||
|
|||||||
@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||||
{
|
{
|
||||||
parentId: currentFolderId,
|
parentId: currentFolderId,
|
||||||
type: FolderType.DOCUMENT,
|
type: FolderType.DOCUMENT,
|
||||||
|
|||||||
@ -127,15 +127,15 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
|
|
||||||
const distributionMethod = watch('meta.distributionMethod');
|
const distributionMethod = watch('meta.distributionMethod');
|
||||||
|
|
||||||
const recipientsMissingSignatureFields = useMemo(
|
const everySignerHasSignature = useMemo(
|
||||||
() =>
|
() =>
|
||||||
envelope.recipients.filter(
|
envelope.recipients
|
||||||
(recipient) =>
|
.filter((recipient) => recipient.role === RecipientRole.SIGNER)
|
||||||
recipient.role === RecipientRole.SIGNER &&
|
.every((recipient) =>
|
||||||
!envelope.fields.some(
|
envelope.fields.some(
|
||||||
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
[envelope.recipients, envelope.fields],
|
[envelope.recipients, envelope.fields],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -178,7 +178,7 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
<Trans>Recipients will be able to sign the document once sent</Trans>
|
<Trans>Recipients will be able to sign the document once sent</Trans>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{recipientsMissingSignatureFields.length === 0 ? (
|
{everySignerHasSignature ? (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
<fieldset disabled={isSubmitting}>
|
<fieldset disabled={isSubmitting}>
|
||||||
@ -350,8 +350,6 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="text-muted-foreground divide-y">
|
<ul className="text-muted-foreground divide-y">
|
||||||
{/* Todo: Envelopes - I don't think this section shows up */}
|
|
||||||
|
|
||||||
{recipients.length === 0 && (
|
{recipients.length === 0 && (
|
||||||
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
<li className="flex flex-col items-center justify-center py-6 text-sm">
|
||||||
<Trans>No recipients</Trans>
|
<Trans>No recipients</Trans>
|
||||||
@ -429,13 +427,10 @@ export const EnvelopeDistributeDialog = ({ envelope, trigger }: EnvelopeDistribu
|
|||||||
<>
|
<>
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>The following signers are missing signature fields:</Trans>
|
<Trans>
|
||||||
|
Some signers have not been assigned a signature field. Please assign at least 1
|
||||||
<ul className="ml-2 mt-1 list-inside list-disc">
|
signature field to each signer before proceeding.
|
||||||
{recipientsMissingSignatureFields.map((recipient) => (
|
</Trans>
|
||||||
<li key={recipient.id}>{recipient.email}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
|
|||||||
const onFormSubmit = async () => {
|
const onFormSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteFolder({
|
await deleteFolder({
|
||||||
id: folder.id,
|
folderId: folder.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|||||||
@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
|
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|
||||||
const form = useForm<TMoveFolderFormSchema>({
|
const form = useForm<TMoveFolderFormSchema>({
|
||||||
resolver: zodResolver(ZMoveFolderFormSchema),
|
resolver: zodResolver(ZMoveFolderFormSchema),
|
||||||
@ -63,12 +63,16 @@ export const FolderMoveDialog = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
|
||||||
if (!folder) return;
|
if (!folder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await moveFolder({
|
await moveFolder({
|
||||||
id: folder.id,
|
folderId: folder.id,
|
||||||
parentId: targetFolderId || null,
|
data: {
|
||||||
|
parentId: targetFolderId || null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
|||||||
@ -61,8 +61,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|
||||||
const isTeamContext = !!team;
|
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
|
||||||
resolver: zodResolver(ZUpdateFolderFormSchema),
|
resolver: zodResolver(ZUpdateFolderFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await updateFolder({
|
await updateFolder({
|
||||||
id: folder.id,
|
folderId: folder.id,
|
||||||
name: data.name,
|
data: {
|
||||||
visibility: isTeamContext
|
name: data.name,
|
||||||
? (data.visibility ?? DocumentVisibility.EVERYONE)
|
visibility: data.visibility,
|
||||||
: DocumentVisibility.EVERYONE,
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@ -140,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isTeamContext && (
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="visibility"
|
||||||
name="visibility"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>
|
||||||
<FormLabel>
|
<Trans>Visibility</Trans>
|
||||||
<Trans>Visibility</Trans>
|
</FormLabel>
|
||||||
</FormLabel>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
<FormControl>
|
||||||
<FormControl>
|
<SelectTrigger>
|
||||||
<SelectTrigger>
|
<SelectValue placeholder={t`Select visibility`} />
|
||||||
<SelectValue placeholder={t`Select visibility`} />
|
</SelectTrigger>
|
||||||
</SelectTrigger>
|
</FormControl>
|
||||||
</FormControl>
|
<SelectContent>
|
||||||
<SelectContent>
|
<SelectItem value={DocumentVisibility.EVERYONE}>
|
||||||
<SelectItem value={DocumentVisibility.EVERYONE}>
|
<Trans>Everyone</Trans>
|
||||||
<Trans>Everyone</Trans>
|
</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
||||||
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
|
<Trans>Managers and above</Trans>
|
||||||
<Trans>Managers and above</Trans>
|
</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value={DocumentVisibility.ADMIN}>
|
||||||
<SelectItem value={DocumentVisibility.ADMIN}>
|
<Trans>Admins only</Trans>
|
||||||
<Trans>Admins only</Trans>
|
</SelectItem>
|
||||||
</SelectItem>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</Select>
|
<FormMessage />
|
||||||
<FormMessage />
|
</FormItem>
|
||||||
</FormItem>
|
)}
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
|
|||||||
@ -185,6 +185,10 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
return 'form';
|
return 'form';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
|
||||||
|
return 'form';
|
||||||
|
}
|
||||||
|
|
||||||
// This is probably going to screw us over in the future.
|
// This is probably going to screw us over in the future.
|
||||||
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
||||||
return 'alert';
|
return 'alert';
|
||||||
|
|||||||
@ -1,15 +1,40 @@
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { createCallable } from 'react-call';
|
import { createCallable } from 'react-call';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
Dialog,
|
||||||
CommandEmpty,
|
DialogContent,
|
||||||
CommandGroup,
|
DialogDescription,
|
||||||
CommandInput,
|
DialogFooter,
|
||||||
CommandItem,
|
DialogHeader,
|
||||||
CommandList,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/command';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@documenso/ui/primitives/select';
|
||||||
|
|
||||||
|
const ZSignFieldDropdownFormSchema = z.object({
|
||||||
|
dropdown: z.string().min(1, { message: msg`Option is required`.id }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TSignFieldDropdownFormSchema = z.infer<typeof ZSignFieldDropdownFormSchema>;
|
||||||
|
|
||||||
export type SignFieldDropdownDialogProps = {
|
export type SignFieldDropdownDialogProps = {
|
||||||
fieldMeta: TDropdownFieldMeta;
|
fieldMeta: TDropdownFieldMeta;
|
||||||
@ -21,20 +46,72 @@ export const SignFieldDropdownDialog = createCallable<SignFieldDropdownDialogPro
|
|||||||
|
|
||||||
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
const values = fieldMeta.values?.map((value) => value.value) ?? [];
|
||||||
|
|
||||||
|
const form = useForm<TSignFieldDropdownFormSchema>({
|
||||||
|
resolver: zodResolver(ZSignFieldDropdownFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
dropdown: fieldMeta.defaultValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
|
||||||
<CommandInput placeholder={t`Select an option`} />
|
<DialogContent position="center">
|
||||||
<CommandList>
|
<DialogHeader>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<DialogTitle>
|
||||||
<CommandGroup heading={t`Options`}>
|
<Trans>Sign Dropdown Field</Trans>
|
||||||
{values.map((value, i) => (
|
</DialogTitle>
|
||||||
<CommandItem onSelect={() => call.end(value)} key={i} value={value}>
|
|
||||||
{value}
|
<DialogDescription className="mt-4">
|
||||||
</CommandItem>
|
<Trans>Select a value to sign into the field</Trans>
|
||||||
))}
|
</DialogDescription>
|
||||||
</CommandGroup>
|
</DialogHeader>
|
||||||
</CommandList>
|
|
||||||
</CommandDialog>
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit((data) => call.end(data.dropdown))}>
|
||||||
|
<fieldset
|
||||||
|
className="flex h-full flex-col space-y-4"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dropdown"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Select {...field} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger className="bg-background">
|
||||||
|
<SelectValue placeholder={t`Select an option`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{values.map((value, i) => (
|
||||||
|
<SelectItem key={i} value={value}>
|
||||||
|
{value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
<Trans>Sign</Trans>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
|
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||||
{
|
{
|
||||||
parentId: currentFolderId ?? null,
|
parentId: currentFolderId ?? null,
|
||||||
type: FolderType.TEMPLATE,
|
type: FolderType.TEMPLATE,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export type EmbedAuthenticationRequiredProps = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
returnTo: string;
|
returnTo: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
oidcProviderLabel?: string;
|
oidcProviderLabel?: string;
|
||||||
};
|
};
|
||||||
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
email,
|
email,
|
||||||
returnTo,
|
returnTo,
|
||||||
// isGoogleSSOEnabled,
|
// isGoogleSSOEnabled,
|
||||||
|
// isMicrosoftSSOEnabled,
|
||||||
// isOIDCSSOEnabled,
|
// isOIDCSSOEnabled,
|
||||||
// oidcProviderLabel,
|
// oidcProviderLabel,
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
}: EmbedAuthenticationRequiredProps) => {
|
||||||
@ -37,6 +39,7 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
<SignInForm
|
<SignInForm
|
||||||
// Embed currently not supported.
|
// Embed currently not supported.
|
||||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
|
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
// oidcProviderLabel={oidcProviderLabel}
|
// oidcProviderLabel={oidcProviderLabel}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
|
|||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
import { EmbedClientLoading } from './embed-client-loading';
|
import { EmbedClientLoading } from './embed-client-loading';
|
||||||
import { EmbedDocumentCompleted } from './embed-document-completed';
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||||
@ -44,6 +45,7 @@ import { EmbedDocumentFields } from './embed-document-fields';
|
|||||||
|
|
||||||
export type EmbedDirectTemplateClientPageProps = {
|
export type EmbedDirectTemplateClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
|
envelopeId: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
@ -55,9 +57,10 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||||||
|
|
||||||
export const EmbedDirectTemplateClientPage = ({
|
export const EmbedDirectTemplateClientPage = ({
|
||||||
token,
|
token,
|
||||||
|
envelopeId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
documentData,
|
documentData,
|
||||||
recipient: _recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
@ -321,9 +324,13 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||||
|
|
||||||
|
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||||
|
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import { BrandingLogo } from '~/components/general/branding-logo';
|
|||||||
import { injectCss } from '~/utils/css-vars';
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
|
||||||
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
|
||||||
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
||||||
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
||||||
@ -48,6 +49,7 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
|
|||||||
export type EmbedSignDocumentClientPageProps = {
|
export type EmbedSignDocumentClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
documentData: DocumentData;
|
documentData: DocumentData;
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
@ -62,6 +64,7 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
export const EmbedSignDocumentClientPage = ({
|
export const EmbedSignDocumentClientPage = ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
|
envelopeId,
|
||||||
documentData,
|
documentData,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
@ -274,15 +277,17 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
||||||
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
||||||
|
|
||||||
{allowDocumentRejection && (
|
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
||||||
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
||||||
|
|
||||||
|
{allowDocumentRejection && (
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
token={token}
|
token={token}
|
||||||
onRejected={onDocumentRejected}
|
onRejected={onDocumentRejected}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
|
|||||||
@ -45,32 +45,26 @@ const ZDropdownFieldFormSchema = z
|
|||||||
.min(1, {
|
.min(1, {
|
||||||
message: msg`Dropdown must have at least one option`.id,
|
message: msg`Dropdown must have at least one option`.id,
|
||||||
})
|
})
|
||||||
.superRefine((values, ctx) => {
|
.refine(
|
||||||
const seen = new Map<string, number[]>(); // value → indices
|
(data) => {
|
||||||
values.forEach((item, index) => {
|
// Todo: Envelopes - This doesn't work.
|
||||||
const key = item.value;
|
console.log({
|
||||||
if (!seen.has(key)) {
|
data,
|
||||||
seen.set(key, []);
|
});
|
||||||
}
|
|
||||||
seen.get(key)!.push(index);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [key, indices] of seen) {
|
if (data) {
|
||||||
if (indices.length > 1 && key.trim() !== '') {
|
const values = data.map((item) => item.value);
|
||||||
for (const i of indices) {
|
return new Set(values).size === values.length;
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: msg`Duplicate values are not allowed`.id,
|
|
||||||
path: [i, 'value'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
return true;
|
||||||
}),
|
},
|
||||||
|
{
|
||||||
|
message: 'Duplicate values are not allowed',
|
||||||
|
},
|
||||||
|
),
|
||||||
required: z.boolean().optional(),
|
required: z.boolean().optional(),
|
||||||
readOnly: z.boolean().optional(),
|
readOnly: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
// Todo: Envelopes - This doesn't work
|
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// Default value must be one of the available options
|
// Default value must be one of the available options
|
||||||
@ -117,20 +111,7 @@ export const EditorFieldDropdownForm = ({
|
|||||||
|
|
||||||
const addValue = () => {
|
const addValue = () => {
|
||||||
const currentValues = form.getValues('values') || [];
|
const currentValues = form.getValues('values') || [];
|
||||||
|
const newValues = [...currentValues, { value: 'New option' }];
|
||||||
let newValue = 'New option';
|
|
||||||
|
|
||||||
// Iterate to create a unique value
|
|
||||||
for (let i = 0; i < currentValues.length; i++) {
|
|
||||||
newValue = `New option ${i + 1}`;
|
|
||||||
if (currentValues.some((item) => item.value === `New option ${i + 1}`)) {
|
|
||||||
newValue = `New option ${i + 1}`;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newValues = [...currentValues, { value: newValue }];
|
|
||||||
|
|
||||||
form.setValue('values', newValues);
|
form.setValue('values', newValues);
|
||||||
};
|
};
|
||||||
@ -146,10 +127,6 @@ export const EditorFieldDropdownForm = ({
|
|||||||
newValues.splice(index, 1);
|
newValues.splice(index, 1);
|
||||||
|
|
||||||
form.setValue('values', newValues);
|
form.setValue('values', newValues);
|
||||||
|
|
||||||
if (form.getValues('defaultValue') === newValues[index].value) {
|
|
||||||
form.setValue('defaultValue', undefined);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -186,26 +163,20 @@ export const EditorFieldDropdownForm = ({
|
|||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
|
// Todo: Envelopes - This is buggy, removing/adding should update the default value.
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value === null ? '-1' : field.value}
|
value={field.value}
|
||||||
onValueChange={(value) => field.onChange(value === undefined ? null : value)}
|
onValueChange={(val) => field.onChange(val)}
|
||||||
>
|
>
|
||||||
{/* Todo: Envelopes - THis is cooked */}
|
|
||||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
||||||
<SelectValue placeholder={t`Default Value`} />
|
<SelectValue placeholder={t`Default Value`} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent position="popper">
|
<SelectContent position="popper">
|
||||||
{(formValues.values || [])
|
{(formValues.values || []).map((item, index) => (
|
||||||
.filter((item) => item.value)
|
<SelectItem key={index} value={item.value || ''}>
|
||||||
.map((item, index) => (
|
{item.value}
|
||||||
<SelectItem key={index} value={item.value || ''}>
|
</SelectItem>
|
||||||
{item.value}
|
))}
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<SelectItem value={'-1'}>
|
|
||||||
<Trans>None</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@ -1,32 +1,15 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { PlusIcon, Trash } from 'lucide-react';
|
import { PlusIcon, Trash } from 'lucide-react';
|
||||||
import { useForm, useWatch } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import type { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import {
|
import { type TRadioFieldMeta as RadioFieldMeta } from '@documenso/lib/types/field-meta';
|
||||||
type TRadioFieldMeta as RadioFieldMeta,
|
|
||||||
ZRadioFieldMeta,
|
|
||||||
} from '@documenso/lib/types/field-meta';
|
|
||||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||||
import {
|
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@documenso/ui/primitives/form/form';
|
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@documenso/ui/primitives/select';
|
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -34,26 +17,31 @@ import {
|
|||||||
EditorGenericRequiredField,
|
EditorGenericRequiredField,
|
||||||
} from './editor-field-generic-field-forms';
|
} from './editor-field-generic-field-forms';
|
||||||
|
|
||||||
const ZRadioFieldFormSchema = ZRadioFieldMeta.pick({
|
const ZRadioFieldFormSchema = z
|
||||||
label: true,
|
.object({
|
||||||
direction: true,
|
label: z.string().optional(),
|
||||||
values: true,
|
values: z
|
||||||
required: true,
|
.object({ id: z.number(), checked: z.boolean(), value: z.string() })
|
||||||
readOnly: true,
|
.array()
|
||||||
}).refine(
|
.min(1)
|
||||||
(data) => {
|
.optional(),
|
||||||
// There cannot be more than one checked option
|
required: z.boolean().optional(),
|
||||||
if (data.values) {
|
readOnly: z.boolean().optional(),
|
||||||
const checkedValues = data.values.filter((option) => option.checked);
|
})
|
||||||
return checkedValues.length <= 1;
|
.refine(
|
||||||
}
|
(data) => {
|
||||||
return true;
|
// There cannot be more than one checked option
|
||||||
},
|
if (data.values) {
|
||||||
{
|
const checkedValues = data.values.filter((option) => option.checked);
|
||||||
message: 'There cannot be more than one checked option',
|
return checkedValues.length <= 1;
|
||||||
path: ['values'],
|
}
|
||||||
},
|
return true;
|
||||||
);
|
},
|
||||||
|
{
|
||||||
|
message: 'There cannot be more than one checked option',
|
||||||
|
path: ['values'],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
type TRadioFieldFormSchema = z.infer<typeof ZRadioFieldFormSchema>;
|
||||||
|
|
||||||
@ -65,12 +53,9 @@ export type EditorFieldRadioFormProps = {
|
|||||||
export const EditorFieldRadioForm = ({
|
export const EditorFieldRadioForm = ({
|
||||||
value = {
|
value = {
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
direction: 'vertical',
|
|
||||||
},
|
},
|
||||||
onValueChange,
|
onValueChange,
|
||||||
}: EditorFieldRadioFormProps) => {
|
}: EditorFieldRadioFormProps) => {
|
||||||
const { t } = useLingui();
|
|
||||||
|
|
||||||
const form = useForm<TRadioFieldFormSchema>({
|
const form = useForm<TRadioFieldFormSchema>({
|
||||||
resolver: zodResolver(ZRadioFieldFormSchema),
|
resolver: zodResolver(ZRadioFieldFormSchema),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@ -79,7 +64,6 @@ export const EditorFieldRadioForm = ({
|
|||||||
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
values: value.values || [{ id: 1, checked: false, value: 'Default value' }],
|
||||||
required: value.required || false,
|
required: value.required || false,
|
||||||
readOnly: value.readOnly || false,
|
readOnly: value.readOnly || false,
|
||||||
direction: value.direction || 'vertical',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -123,35 +107,7 @@ export const EditorFieldRadioForm = ({
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form>
|
<form>
|
||||||
<fieldset className="flex flex-col gap-2">
|
<fieldset className="flex flex-col gap-2 pb-2">
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="direction"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
<Trans>Direction</Trans>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger className="text-muted-foreground bg-background w-full">
|
|
||||||
<SelectValue placeholder={t`Select direction`} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent position="popper">
|
|
||||||
<SelectItem value="vertical">
|
|
||||||
<Trans>Vertical</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="horizontal">
|
|
||||||
<Trans>Horizontal</Trans>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditorGenericRequiredField formControl={form.control} />
|
<EditorGenericRequiredField formControl={form.control} />
|
||||||
|
|
||||||
<EditorGenericReadOnlyField formControl={form.control} />
|
<EditorGenericReadOnlyField formControl={form.control} />
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export type SignInFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
oidcProviderLabel?: string;
|
oidcProviderLabel?: string;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
@ -79,6 +80,7 @@ export const SignInForm = ({
|
|||||||
className,
|
className,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
returnTo,
|
returnTo,
|
||||||
@ -90,11 +92,14 @@ export const SignInForm = ({
|
|||||||
|
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
>('totp');
|
>('totp');
|
||||||
|
|
||||||
|
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||||
|
|
||||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
const redirectPath = useMemo(() => {
|
const redirectPath = useMemo(() => {
|
||||||
@ -271,6 +276,22 @@ export const SignInForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignInWithMicrosoftClick = async () => {
|
||||||
|
try {
|
||||||
|
await authClient.microsoft.signIn({
|
||||||
|
redirectPath,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSignInWithOIDCClick = async () => {
|
const onSignInWithOIDCClick = async () => {
|
||||||
try {
|
try {
|
||||||
await authClient.oidc.signIn({
|
await authClient.oidc.signIn({
|
||||||
@ -297,6 +318,8 @@ export const SignInForm = ({
|
|||||||
if (email) {
|
if (email) {
|
||||||
form.setValue('email', email);
|
form.setValue('email', email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -363,42 +386,64 @@ export const SignInForm = ({
|
|||||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
{!isEmbeddedRedirect && (
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<>
|
||||||
<div className="bg-border h-px flex-1" />
|
{hasSocialAuthEnabled && (
|
||||||
<span className="text-muted-foreground bg-transparent">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<Trans>Or continue with</Trans>
|
<div className="bg-border h-px flex-1" />
|
||||||
</span>
|
<span className="text-muted-foreground bg-transparent">
|
||||||
<div className="bg-border h-px flex-1" />
|
<Trans>Or continue with</Trans>
|
||||||
</div>
|
</span>
|
||||||
)}
|
<div className="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
{isGoogleSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithGoogleClick}
|
onClick={onSignInWithGoogleClick}
|
||||||
>
|
>
|
||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
{isMicrosoftSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithOIDCClick}
|
onClick={onSignInWithMicrosoftClick}
|
||||||
>
|
>
|
||||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
<img
|
||||||
{oidcProviderLabel || 'OIDC'}
|
className="mr-2 h-4 w-4"
|
||||||
</Button>
|
alt="Microsoft Logo"
|
||||||
|
src={'/static/microsoft.svg'}
|
||||||
|
/>
|
||||||
|
Microsoft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOIDCSSOEnabled && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignInWithOIDCClick}
|
||||||
|
>
|
||||||
|
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||||
|
{oidcProviderLabel || 'OIDC'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -66,14 +66,18 @@ export type SignUpFormProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
initialEmail?: string;
|
initialEmail?: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({
|
export const SignUpForm = ({
|
||||||
className,
|
className,
|
||||||
initialEmail,
|
initialEmail,
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
|
returnTo,
|
||||||
}: SignUpFormProps) => {
|
}: SignUpFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -84,6 +88,8 @@ export const SignUpForm = ({
|
|||||||
|
|
||||||
const utmSrc = searchParams.get('utm_source') ?? null;
|
const utmSrc = searchParams.get('utm_source') ?? null;
|
||||||
|
|
||||||
|
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
|
||||||
|
|
||||||
const form = useForm<TSignUpFormSchema>({
|
const form = useForm<TSignUpFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
name: '',
|
name: '',
|
||||||
@ -106,7 +112,7 @@ export const SignUpForm = ({
|
|||||||
signature,
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`/unverified-account`);
|
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Registration Successful`),
|
title: _(msg`Registration Successful`),
|
||||||
@ -148,6 +154,20 @@ export const SignUpForm = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSignUpWithMicrosoftClick = async () => {
|
||||||
|
try {
|
||||||
|
await authClient.microsoft.signIn();
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`An unknown error occurred`),
|
||||||
|
description: _(
|
||||||
|
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
|
||||||
|
),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSignUpWithOIDCClick = async () => {
|
const onSignUpWithOIDCClick = async () => {
|
||||||
try {
|
try {
|
||||||
await authClient.oidc.signIn();
|
await authClient.oidc.signIn();
|
||||||
@ -227,7 +247,7 @@ export const SignUpForm = ({
|
|||||||
<fieldset
|
<fieldset
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[550px] w-full flex-col gap-y-4',
|
'flex h-[550px] w-full flex-col gap-y-4',
|
||||||
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
|
hasSocialAuthEnabled && 'h-[650px]',
|
||||||
)}
|
)}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
@ -302,7 +322,7 @@ export const SignUpForm = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
|
{hasSocialAuthEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<div className="bg-border h-px flex-1" />
|
<div className="bg-border h-px flex-1" />
|
||||||
@ -330,6 +350,26 @@ export const SignUpForm = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isMicrosoftSSOEnabled && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
variant={'outline'}
|
||||||
|
className="bg-background text-muted-foreground border"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={onSignUpWithMicrosoftClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
className="mr-2 h-4 w-4"
|
||||||
|
alt="Microsoft Logo"
|
||||||
|
src={'/static/microsoft.svg'}
|
||||||
|
/>
|
||||||
|
<Trans>Sign Up with Microsoft</Trans>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
{isOIDCSSOEnabled && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { ExternalLink, PaperclipIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
|
||||||
|
export type DocumentSigningAttachmentsPopoverProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DocumentSigningAttachmentsPopover = ({
|
||||||
|
envelopeId,
|
||||||
|
token,
|
||||||
|
}: DocumentSigningAttachmentsPopoverProps) => {
|
||||||
|
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||||
|
envelopeId,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachments || attachments.data.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<PaperclipIcon className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
<Trans>Attachments</Trans>{' '}
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<span className="ml-1">({attachments.data.length})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-96" align="start">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>Documents and resources related to this envelope.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments?.data.map((attachment) => (
|
||||||
|
<a
|
||||||
|
key={attachment.id}
|
||||||
|
href={attachment.data}
|
||||||
|
title={attachment.data}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 items-center gap-2.5">
|
||||||
|
<div className="bg-muted rounded p-2">
|
||||||
|
<PaperclipIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
|
||||||
|
{attachment.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
actionVerb = 'sign',
|
actionVerb = 'sign',
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentSigningAuthAccountProps) => {
|
}: DocumentSigningAuthAccountProps) => {
|
||||||
const { recipient } = useRequiredDocumentSigningAuthContext();
|
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -34,8 +34,10 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
redirectPath: `/signin#email=${email}`,
|
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
{isDirectTemplate ? (
|
||||||
To mark this document as viewed, you need to be logged in as{' '}
|
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||||
<strong>{recipient.email}</strong>
|
) : (
|
||||||
</Trans>
|
<Trans>
|
||||||
|
To mark this document as viewed, you need to be logged in as{' '}
|
||||||
|
<strong>{recipient.email}</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{/* Todo: Translate */}
|
{isDirectTemplate ? (
|
||||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
<Trans>
|
||||||
in as <strong>{recipient.email}</strong>
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||||
|
logged in.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||||
|
logged in as <strong>{recipient.email}</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@ -47,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onReauthFormSubmit,
|
onReauthFormSubmit,
|
||||||
}: DocumentSigningAuthDialogProps) => {
|
}: DocumentSigningAuthDialogProps) => {
|
||||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
||||||
|
useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||||
const validAuthTypes = availableAuthTypes.filter(
|
const validAuthTypes = availableAuthTypes.filter(
|
||||||
@ -168,7 +169,11 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
match({ documentAuthType: selectedAuthType, user })
|
match({ documentAuthType: selectedAuthType, user })
|
||||||
.with(
|
.with(
|
||||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
{
|
||||||
|
user: P.when(
|
||||||
|
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
|
||||||
|
),
|
||||||
|
}, // Assume all current auth methods requires them to be logged in.
|
||||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||||
)
|
)
|
||||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export type DocumentSigningAuthContextValue = {
|
|||||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||||
isAuthRedirectRequired: boolean;
|
isAuthRedirectRequired: boolean;
|
||||||
|
isDirectTemplate?: boolean;
|
||||||
isCurrentlyAuthenticating: boolean;
|
isCurrentlyAuthenticating: boolean;
|
||||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||||
passkeyData: PasskeyData;
|
passkeyData: PasskeyData;
|
||||||
@ -65,6 +66,7 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
|||||||
export interface DocumentSigningAuthProviderProps {
|
export interface DocumentSigningAuthProviderProps {
|
||||||
documentAuthOptions: Envelope['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
recipient: SigningAuthRecipient;
|
recipient: SigningAuthRecipient;
|
||||||
|
isDirectTemplate?: boolean;
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@ -72,6 +74,7 @@ export interface DocumentSigningAuthProviderProps {
|
|||||||
export const DocumentSigningAuthProvider = ({
|
export const DocumentSigningAuthProvider = ({
|
||||||
documentAuthOptions: initialDocumentAuthOptions,
|
documentAuthOptions: initialDocumentAuthOptions,
|
||||||
recipient: initialRecipient,
|
recipient: initialRecipient,
|
||||||
|
isDirectTemplate = false,
|
||||||
user,
|
user,
|
||||||
children,
|
children,
|
||||||
}: DocumentSigningAuthProviderProps) => {
|
}: DocumentSigningAuthProviderProps) => {
|
||||||
@ -201,6 +204,7 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
derivedRecipientAccessAuth,
|
derivedRecipientAccessAuth,
|
||||||
derivedRecipientActionAuth,
|
derivedRecipientActionAuth,
|
||||||
isAuthRedirectRequired,
|
isAuthRedirectRequired,
|
||||||
|
isDirectTemplate,
|
||||||
isCurrentlyAuthenticating,
|
isCurrentlyAuthenticating,
|
||||||
setIsCurrentlyAuthenticating,
|
setIsCurrentlyAuthenticating,
|
||||||
passkeyData,
|
passkeyData,
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
|
|||||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
|
||||||
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
|
||||||
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
|
||||||
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
|
||||||
@ -231,7 +232,13 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
<div className="flex items-center gap-x-4">
|
||||||
|
<DocumentSigningAttachmentsPopover
|
||||||
|
envelopeId={document.envelopeId}
|
||||||
|
token={recipient.token}
|
||||||
|
/>
|
||||||
|
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
|
|||||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||||
|
|
||||||
|
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||||
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
||||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||||
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
|
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
|
||||||
@ -31,8 +32,13 @@ const EnvelopeSignerPageRenderer = lazy(
|
|||||||
export const DocumentSigningPageViewV2 = () => {
|
export const DocumentSigningPageViewV2 = () => {
|
||||||
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const { envelope, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip } =
|
const {
|
||||||
useRequiredEnvelopeSigningContext();
|
envelope,
|
||||||
|
recipient,
|
||||||
|
recipientFields,
|
||||||
|
recipientFieldsRemaining,
|
||||||
|
showPendingFieldTooltip,
|
||||||
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-gray-50">
|
<div className="h-screen w-screen bg-gray-50">
|
||||||
@ -83,6 +89,10 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
<Trans>Actions</Trans>
|
<Trans>Actions</Trans>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Todo: Allow selecting which document to download and/or the original */}
|
{/* Todo: Allow selecting which document to download and/or the original */}
|
||||||
<Button variant="ghost" size="sm" className="w-full justify-start">
|
<Button variant="ghost" size="sm" className="w-full justify-start">
|
||||||
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
<DownloadCloudIcon className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@ -0,0 +1,241 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { Paperclip, Plus, X } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormMessage,
|
||||||
|
} from '@documenso/ui/primitives/form/form';
|
||||||
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
export type DocumentAttachmentsPopoverProps = {
|
||||||
|
envelopeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZAttachmentFormSchema = z.object({
|
||||||
|
label: z.string().min(1, 'Label is required'),
|
||||||
|
url: z.string().url('Must be a valid URL'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
|
||||||
|
envelopeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: createAttachment, isPending: isCreating } =
|
||||||
|
trpc.envelope.attachment.create.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.envelope.attachment.find.invalidate({ envelopeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<TAttachmentFormSchema>({
|
||||||
|
resolver: zodResolver(ZAttachmentFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
label: '',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: TAttachmentFormSchema) => {
|
||||||
|
try {
|
||||||
|
await createAttachment({
|
||||||
|
envelopeId,
|
||||||
|
data: {
|
||||||
|
label: data.label,
|
||||||
|
data: data.url,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
|
||||||
|
setIsAdding(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Attachment added successfully.`),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteAttachment = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteAttachment({ id });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Success`),
|
||||||
|
description: _(msg`Attachment removed successfully.`),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Error`),
|
||||||
|
description: error.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<span className="ml-1">({attachments.data.length})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-96" align="end">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium">
|
||||||
|
<Trans>Attachments</Trans>
|
||||||
|
</h4>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
<Trans>Add links to relevant documents or resources.</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{attachments && attachments.data.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attachments?.data.map((attachment) => (
|
||||||
|
<div
|
||||||
|
key={attachment.id}
|
||||||
|
className="border-border flex items-center justify-between rounded-md border p-2"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{attachment.label}</p>
|
||||||
|
<a
|
||||||
|
href={attachment.data}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
|
||||||
|
>
|
||||||
|
{attachment.data}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void onDeleteAttachment(attachment.id)}
|
||||||
|
className="ml-2 h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdding && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
<Trans>Add Attachment</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdding && (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="label"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={_(msg`Label`)} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="url" placeholder={_(msg`URL`)} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAdding(false);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -96,7 +96,7 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
selectedRecipientId,
|
selectedRecipientId,
|
||||||
selectedEnvelopeItemId,
|
selectedEnvelopeItemId,
|
||||||
}: EnvelopeEditorFieldDragDropProps) => {
|
}: EnvelopeEditorFieldDragDropProps) => {
|
||||||
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, isTemplate } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -262,10 +262,6 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
};
|
};
|
||||||
}, [onMouseClick, onMouseMove, selectedField]);
|
}, [onMouseClick, onMouseMove, selectedField]);
|
||||||
|
|
||||||
const selectedRecipientColor = useMemo(() => {
|
|
||||||
return selectedRecipientId ? getRecipientColorKey(selectedRecipientId) : 'green';
|
|
||||||
}, [selectedRecipientId, getRecipientColorKey]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-2.5">
|
||||||
@ -277,23 +273,12 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
onClick={() => setSelectedField(field.type)}
|
onClick={() => setSelectedField(field.type)}
|
||||||
onMouseDown={() => setSelectedField(field.type)}
|
onMouseDown={() => setSelectedField(field.type)}
|
||||||
data-selected={selectedField === field.type ? true : undefined}
|
data-selected={selectedField === field.type ? true : undefined}
|
||||||
className={cn(
|
className="group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors hover:border-blue-300 hover:bg-blue-50"
|
||||||
'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-gray-200 px-4 transition-colors',
|
|
||||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
'text-muted-foreground group-data-[selected]:text-foreground flex items-center justify-center gap-x-1.5 text-sm font-normal',
|
||||||
field.className,
|
field.className,
|
||||||
{
|
|
||||||
'group-hover:text-recipient-green': selectedRecipientColor === 'green',
|
|
||||||
'group-hover:text-recipient-blue': selectedRecipientColor === 'blue',
|
|
||||||
'group-hover:text-recipient-purple': selectedRecipientColor === 'purple',
|
|
||||||
'group-hover:text-recipient-orange': selectedRecipientColor === 'orange',
|
|
||||||
'group-hover:text-recipient-yellow': selectedRecipientColor === 'yellow',
|
|
||||||
'group-hover:text-recipient-pink': selectedRecipientColor === 'pink',
|
|
||||||
},
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
{field.type !== FieldType.SIGNATURE && <field.icon className="h-4 w-4" />}
|
||||||
@ -307,7 +292,8 @@ export const EnvelopeEditorFieldDragDrop = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
'text-muted-foreground dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white ring-2 transition duration-200 [container-type:size]',
|
||||||
RECIPIENT_COLOR_STYLES[selectedRecipientColor].base,
|
// selectedSignerStyles?.base,
|
||||||
|
RECIPIENT_COLOR_STYLES.yellow.base, // Todo: Envelopes
|
||||||
{
|
{
|
||||||
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
|
||||||
'dark:text-black/60': isFieldWithinBounds,
|
'dark:text-black/60': isFieldWithinBounds,
|
||||||
|
|||||||
@ -3,12 +3,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import type { FieldType } from '@prisma/client';
|
import type { FieldType } from '@prisma/client';
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
|
import type { Layer } from 'konva/lib/Layer';
|
||||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
import type { Transformer } from 'konva/lib/shapes/Transformer';
|
||||||
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
|
import { CopyPlusIcon, SquareStackIcon, TrashIcon } from 'lucide-react';
|
||||||
|
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { usePageContext } from 'react-pdf';
|
||||||
|
|
||||||
|
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
|
||||||
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
||||||
@ -23,10 +26,27 @@ import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
|||||||
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
import { fieldButtonList } from './envelope-editor-fields-drag-drop';
|
||||||
|
|
||||||
export default function EnvelopeEditorFieldsPageRenderer() {
|
export default function EnvelopeEditorFieldsPageRenderer() {
|
||||||
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
|
if (!pageContext) {
|
||||||
|
throw new Error('Unable to find Page context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _className, page, rotate, scale } = pageContext;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
|
const pageLayer = useRef<Layer | null>(null);
|
||||||
const interactiveTransformer = useRef<Transformer | null>(null);
|
const interactiveTransformer = useRef<Transformer | null>(null);
|
||||||
|
|
||||||
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
const [selectedKonvaFieldGroups, setSelectedKonvaFieldGroups] = useState<Konva.Group[]>([]);
|
||||||
@ -34,17 +54,10 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
const [isFieldChanging, setIsFieldChanging] = useState(false);
|
||||||
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
const [pendingFieldCreation, setPendingFieldCreation] = useState<Konva.Rect | null>(null);
|
||||||
|
|
||||||
const {
|
const viewport = useMemo(
|
||||||
stage,
|
() => page.getViewport({ scale, rotation: rotate }),
|
||||||
pageLayer,
|
[page, rotate, scale],
|
||||||
canvasElement,
|
);
|
||||||
konvaContainer,
|
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
|
||||||
unscaledViewport,
|
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -55,6 +68,44 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
[editorFields.localFields, pageContext.pageNumber],
|
[editorFields.localFields, pageContext.pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Custom renderer from Konva examples.
|
||||||
|
useEffect(
|
||||||
|
function drawPageOnCanvas() {
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current: canvas } = canvasElement;
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (!canvas || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContext: RenderParameters = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellable = page.render(renderContext);
|
||||||
|
const runningTask = cancellable;
|
||||||
|
|
||||||
|
cancellable.promise.catch(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancellable.promise.then(() => {
|
||||||
|
createPageCanvas(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runningTask.cancel();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||||
console.log('Field resized or moved');
|
console.log('Field resized or moved');
|
||||||
|
|
||||||
@ -69,7 +120,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const fieldGroup = event.target as Konva.Group;
|
const fieldGroup = event.target as Konva.Group;
|
||||||
const fieldFormId = fieldGroup.id();
|
const fieldFormId = fieldGroup.id();
|
||||||
|
|
||||||
// Note: This values are scaled.
|
|
||||||
const {
|
const {
|
||||||
width: fieldPixelWidth,
|
width: fieldPixelWidth,
|
||||||
height: fieldPixelHeight,
|
height: fieldPixelHeight,
|
||||||
@ -80,8 +130,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
skipShadow: true,
|
skipShadow: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageHeight = scaledViewport.height;
|
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(container);
|
||||||
const pageWidth = scaledViewport.width;
|
|
||||||
|
|
||||||
// Calculate x and y as a percentage of the page width and height
|
// Calculate x and y as a percentage of the page width and height
|
||||||
const positionPercentX = (fieldX / pageWidth) * 100;
|
const positionPercentX = (fieldX / pageWidth) * 100;
|
||||||
@ -116,7 +165,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TLocalField) => {
|
const renderFieldOnLayer = (field: TLocalField) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current || !interactiveTransformer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -125,8 +174,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
const isFieldEditable =
|
const isFieldEditable =
|
||||||
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
recipient !== undefined && canRecipientFieldsBeModified(recipient, envelope.fields);
|
||||||
|
|
||||||
const { fieldGroup } = renderField({
|
const { fieldGroup, isFirstRender } = renderField({
|
||||||
scale,
|
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.formId,
|
renderId: field.formId,
|
||||||
@ -135,8 +183,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
inserted: false,
|
inserted: false,
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
},
|
},
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: viewport.width,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight: viewport.height,
|
||||||
color: getRecipientColorKey(field.recipientId),
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: isFieldEditable,
|
editable: isFieldEditable,
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
@ -162,14 +210,24 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
const createPageCanvas = (container: HTMLDivElement) => {
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
stage.current?.add(pageLayer.current);
|
||||||
|
|
||||||
// Initialize snap guides layer
|
// Initialize snap guides layer
|
||||||
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
// snapGuideLayer.current = initializeSnapGuides(stage.current);
|
||||||
|
|
||||||
// Add transformer for resizing and rotating.
|
// Add transformer for resizing and rotating.
|
||||||
interactiveTransformer.current = createInteractiveTransformer(currentStage, currentPageLayer);
|
interactiveTransformer.current = createInteractiveTransformer(stage.current, pageLayer.current);
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
@ -177,12 +235,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle stage click to deselect.
|
// Handle stage click to deselect.
|
||||||
currentStage.on('click', (e) => {
|
stage.current?.on('click', (e) => {
|
||||||
removePendingField();
|
removePendingField();
|
||||||
|
|
||||||
if (e.target === stage.current) {
|
if (e.target === stage.current) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
currentPageLayer.batchDraw();
|
pageLayer.current?.batchDraw();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -209,12 +267,12 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
setSelectedFields([e.target]);
|
setSelectedFields([e.target]);
|
||||||
};
|
};
|
||||||
|
|
||||||
currentStage.on('dragstart', onDragStartOrEnd);
|
stage.current?.on('dragstart', onDragStartOrEnd);
|
||||||
currentStage.on('dragend', onDragStartOrEnd);
|
stage.current?.on('dragend', onDragStartOrEnd);
|
||||||
currentStage.on('transformstart', () => setIsFieldChanging(true));
|
stage.current?.on('transformstart', () => setIsFieldChanging(true));
|
||||||
currentStage.on('transformend', () => setIsFieldChanging(false));
|
stage.current?.on('transformend', () => setIsFieldChanging(false));
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -226,10 +284,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
* - Selecting multiple fields
|
* - Selecting multiple fields
|
||||||
* - Selecting empty area to create fields
|
* - Selecting empty area to create fields
|
||||||
*/
|
*/
|
||||||
const createInteractiveTransformer = (
|
const createInteractiveTransformer = (stage: Konva.Stage, layer: Konva.Layer) => {
|
||||||
currentStage: Konva.Stage,
|
|
||||||
currentPageLayer: Konva.Layer,
|
|
||||||
) => {
|
|
||||||
const transformer = new Konva.Transformer({
|
const transformer = new Konva.Transformer({
|
||||||
rotateEnabled: false,
|
rotateEnabled: false,
|
||||||
keepRatio: false,
|
keepRatio: false,
|
||||||
@ -246,39 +301,36 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
currentPageLayer.add(transformer);
|
layer.add(transformer);
|
||||||
|
|
||||||
// Add selection rectangle.
|
// Add selection rectangle.
|
||||||
const selectionRectangle = new Konva.Rect({
|
const selectionRectangle = new Konva.Rect({
|
||||||
fill: 'rgba(24, 160, 251, 0.3)',
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
visible: false,
|
visible: false,
|
||||||
});
|
});
|
||||||
currentPageLayer.add(selectionRectangle);
|
layer.add(selectionRectangle);
|
||||||
|
|
||||||
let x1: number;
|
let x1: number;
|
||||||
let y1: number;
|
let y1: number;
|
||||||
let x2: number;
|
let x2: number;
|
||||||
let y2: number;
|
let y2: number;
|
||||||
|
|
||||||
currentStage.on('mousedown touchstart', (e) => {
|
stage.on('mousedown touchstart', (e) => {
|
||||||
// do nothing if we mousedown on any shape
|
// do nothing if we mousedown on any shape
|
||||||
if (e.target !== currentStage) {
|
if (e.target !== stage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerPosition = currentStage.getPointerPosition();
|
const pointerPosition = stage.getPointerPosition();
|
||||||
|
|
||||||
if (!pointerPosition) {
|
if (!pointerPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`pointerPosition.x: ${pointerPosition.x}`);
|
x1 = pointerPosition.x;
|
||||||
console.log(`pointerPosition.y: ${pointerPosition.y}`);
|
y1 = pointerPosition.y;
|
||||||
|
x2 = pointerPosition.x;
|
||||||
x1 = pointerPosition.x / scale;
|
y2 = pointerPosition.y;
|
||||||
y1 = pointerPosition.y / scale;
|
|
||||||
x2 = pointerPosition.x / scale;
|
|
||||||
y2 = pointerPosition.y / scale;
|
|
||||||
|
|
||||||
selectionRectangle.setAttrs({
|
selectionRectangle.setAttrs({
|
||||||
x: x1,
|
x: x1,
|
||||||
@ -289,7 +341,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
currentStage.on('mousemove touchmove', () => {
|
stage.on('mousemove touchmove', () => {
|
||||||
// do nothing if we didn't start selection
|
// do nothing if we didn't start selection
|
||||||
if (!selectionRectangle.visible()) {
|
if (!selectionRectangle.visible()) {
|
||||||
return;
|
return;
|
||||||
@ -297,14 +349,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
|
|
||||||
selectionRectangle.moveToTop();
|
selectionRectangle.moveToTop();
|
||||||
|
|
||||||
const pointerPosition = currentStage.getPointerPosition();
|
const pointerPosition = stage.getPointerPosition();
|
||||||
|
|
||||||
if (!pointerPosition) {
|
if (!pointerPosition) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
x2 = pointerPosition.x / scale;
|
x2 = pointerPosition.x;
|
||||||
y2 = pointerPosition.y / scale;
|
y2 = pointerPosition.y;
|
||||||
|
|
||||||
selectionRectangle.setAttrs({
|
selectionRectangle.setAttrs({
|
||||||
x: Math.min(x1, x2),
|
x: Math.min(x1, x2),
|
||||||
@ -314,7 +366,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
currentStage.on('mouseup touchend', () => {
|
stage.on('mouseup touchend', () => {
|
||||||
// do nothing if we didn't start selection
|
// do nothing if we didn't start selection
|
||||||
if (!selectionRectangle.visible()) {
|
if (!selectionRectangle.visible()) {
|
||||||
return;
|
return;
|
||||||
@ -325,41 +377,38 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
selectionRectangle.visible(false);
|
selectionRectangle.visible(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
const stageFieldGroups = currentStage.find('.field-group') || [];
|
const stageFieldGroups = stage.find('.field-group') || [];
|
||||||
const box = selectionRectangle.getClientRect();
|
const box = selectionRectangle.getClientRect();
|
||||||
const selectedFieldGroups = stageFieldGroups.filter(
|
const selectedFieldGroups = stageFieldGroups.filter(
|
||||||
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
(shape) => Konva.Util.haveIntersection(box, shape.getClientRect()) && shape.draggable(),
|
||||||
);
|
);
|
||||||
setSelectedFields(selectedFieldGroups);
|
setSelectedFields(selectedFieldGroups);
|
||||||
|
|
||||||
const unscaledBoxWidth = box.width / scale;
|
|
||||||
const unscaledBoxHeight = box.height / scale;
|
|
||||||
|
|
||||||
// Create a field if no items are selected or the size is too small.
|
// Create a field if no items are selected or the size is too small.
|
||||||
if (
|
if (
|
||||||
selectedFieldGroups.length === 0 &&
|
selectedFieldGroups.length === 0 &&
|
||||||
canvasElement.current &&
|
canvasElement.current &&
|
||||||
unscaledBoxWidth > MIN_FIELD_WIDTH_PX &&
|
box.width > MIN_FIELD_WIDTH_PX &&
|
||||||
unscaledBoxHeight > MIN_FIELD_HEIGHT_PX &&
|
box.height > MIN_FIELD_HEIGHT_PX &&
|
||||||
editorFields.selectedRecipient &&
|
editorFields.selectedRecipient &&
|
||||||
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields)
|
||||||
) {
|
) {
|
||||||
const pendingFieldCreation = new Konva.Rect({
|
const pendingFieldCreation = new Konva.Rect({
|
||||||
name: 'pending-field-creation',
|
name: 'pending-field-creation',
|
||||||
x: box.x / scale,
|
x: box.x,
|
||||||
y: box.y / scale,
|
y: box.y,
|
||||||
width: unscaledBoxWidth,
|
width: box.width,
|
||||||
height: unscaledBoxHeight,
|
height: box.height,
|
||||||
fill: 'rgba(24, 160, 251, 0.3)',
|
fill: 'rgba(24, 160, 251, 0.3)',
|
||||||
});
|
});
|
||||||
|
|
||||||
currentPageLayer.add(pendingFieldCreation);
|
layer.add(pendingFieldCreation);
|
||||||
setPendingFieldCreation(pendingFieldCreation);
|
setPendingFieldCreation(pendingFieldCreation);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clicks should select/deselect shapes
|
// Clicks should select/deselect shapes
|
||||||
currentStage.on('click tap', function (e) {
|
stage.on('click tap', function (e) {
|
||||||
// if we are selecting with rect, do nothing
|
// if we are selecting with rect, do nothing
|
||||||
if (
|
if (
|
||||||
selectionRectangle.visible() &&
|
selectionRectangle.visible() &&
|
||||||
@ -370,7 +419,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If empty area clicked, remove all selections
|
// If empty area clicked, remove all selections
|
||||||
if (e.target === stage.current) {
|
if (e.target === stage) {
|
||||||
setSelectedFields([]);
|
setSelectedFields([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -506,13 +555,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { height: pageHeight, width: pageWidth } = getBoundingClientRect(canvasElement.current);
|
||||||
|
|
||||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
const { fieldX, fieldY, fieldWidth, fieldHeight } = convertPixelToPercentage({
|
||||||
width: pixelWidth,
|
width: pixelWidth,
|
||||||
height: pixelHeight,
|
height: pixelHeight,
|
||||||
positionX: pixelX,
|
positionX: pixelX,
|
||||||
positionY: pixelY,
|
positionY: pixelY,
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
editorFields.addField({
|
editorFields.addField({
|
||||||
@ -546,10 +597,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||||
className="relative w-full"
|
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{selectedKonvaFieldGroups.length > 0 &&
|
{selectedKonvaFieldGroups.length > 0 &&
|
||||||
interactiveTransformer.current &&
|
interactiveTransformer.current &&
|
||||||
!isFieldChanging && (
|
!isFieldChanging && (
|
||||||
@ -606,15 +654,8 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top:
|
top: pendingFieldCreation.y() + pendingFieldCreation.getClientRect().height + 5 + 'px',
|
||||||
pendingFieldCreation.y() * scale +
|
left: pendingFieldCreation.x() + pendingFieldCreation.getClientRect().width / 2 + 'px',
|
||||||
pendingFieldCreation.getClientRect().height +
|
|
||||||
5 +
|
|
||||||
'px',
|
|
||||||
left:
|
|
||||||
pendingFieldCreation.x() * scale +
|
|
||||||
pendingFieldCreation.getClientRect().width / 2 +
|
|
||||||
'px',
|
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
@ -632,15 +673,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
|
height={viewport.height}
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
height={scaledViewport.height}
|
width={viewport.width}
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
|
|||||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||||
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
|
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
@ -131,6 +132,8 @@ export default function EnvelopeEditorHeader() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<DocumentAttachmentsPopover envelopeId={envelope.id} />
|
||||||
|
|
||||||
<EnvelopeEditorSettingsDialog
|
<EnvelopeEditorSettingsDialog
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
|
|||||||
@ -60,7 +60,7 @@ const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|||||||
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeEditorFieldsPage = () => {
|
export const EnvelopeEditorPageFields = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
@ -109,7 +109,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex justify-center p-4">
|
<div className="mt-4 flex justify-center">
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
|
||||||
) : (
|
) : (
|
||||||
@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import Konva from 'konva';
|
||||||
|
import type { Layer } from 'konva/lib/Layer';
|
||||||
|
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { usePageContext } from 'react-pdf';
|
||||||
|
|
||||||
|
import type { TLocalField } from '@documenso/lib/client-only/hooks/use-editor-fields';
|
||||||
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
|
|
||||||
|
export default function EnvelopeEditorPagePreviewRenderer() {
|
||||||
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
|
if (!pageContext) {
|
||||||
|
throw new Error('Unable to find Page context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _className, page, rotate, scale } = pageContext;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
|
||||||
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
|
const pageLayer = useRef<Layer | null>(null);
|
||||||
|
|
||||||
|
const viewport = useMemo(
|
||||||
|
() => page.getViewport({ scale, rotation: rotate }),
|
||||||
|
[page, rotate, scale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const localPageFields = useMemo(
|
||||||
|
() =>
|
||||||
|
editorFields.localFields.filter(
|
||||||
|
(field) =>
|
||||||
|
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
|
),
|
||||||
|
[editorFields.localFields, pageContext.pageNumber],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom renderer from Konva examples.
|
||||||
|
useEffect(
|
||||||
|
function drawPageOnCanvas() {
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current: canvas } = canvasElement;
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (!canvas || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContext: RenderParameters = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellable = page.render(renderContext);
|
||||||
|
const runningTask = cancellable;
|
||||||
|
|
||||||
|
cancellable.promise.catch(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancellable.promise.then(() => {
|
||||||
|
createPageCanvas(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runningTask.cancel();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFieldOnLayer = (field: TLocalField) => {
|
||||||
|
if (!pageLayer.current) {
|
||||||
|
console.error('Layer not loaded yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderField({
|
||||||
|
pageLayer: pageLayer.current,
|
||||||
|
field: {
|
||||||
|
renderId: field.formId,
|
||||||
|
...field,
|
||||||
|
customText: '',
|
||||||
|
inserted: false,
|
||||||
|
fieldMeta: field.fieldMeta,
|
||||||
|
},
|
||||||
|
pageWidth: viewport.width,
|
||||||
|
pageHeight: viewport.height,
|
||||||
|
color: getRecipientColorKey(field.recipientId),
|
||||||
|
editable: false,
|
||||||
|
mode: 'export',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||||
|
*/
|
||||||
|
const createPageCanvas = (container: HTMLDivElement) => {
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
stage.current?.add(pageLayer.current);
|
||||||
|
|
||||||
|
// Render the fields.
|
||||||
|
for (const field of localPageFields) {
|
||||||
|
renderFieldOnLayer(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageLayer.current.batchDraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render fields when they are added or removed from the localFields.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pageLayer.current || !stage.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If doesn't exist in localFields, destroy it since it's been deleted.
|
||||||
|
pageLayer.current.find('Group').forEach((group) => {
|
||||||
|
if (
|
||||||
|
group.name() === 'field-group' &&
|
||||||
|
!localPageFields.some((field) => field.formId === group.id())
|
||||||
|
) {
|
||||||
|
console.log('Field removed, removing from canvas');
|
||||||
|
group.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If it exists, rerender.
|
||||||
|
localPageFields.forEach((field) => {
|
||||||
|
console.log('Field created/updated, rendering on canvas');
|
||||||
|
renderFieldOnLayer(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
pageLayer.current.batchDraw();
|
||||||
|
}, [localPageFields]);
|
||||||
|
|
||||||
|
if (!currentEnvelopeItem) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||||
|
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||||
|
|
||||||
|
<canvas
|
||||||
|
className={`${_className}__canvas z-0`}
|
||||||
|
height={viewport.height}
|
||||||
|
ref={canvasElement}
|
||||||
|
width={viewport.width}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,9 +13,11 @@ import { Separator } from '@documenso/ui/primitives/separator';
|
|||||||
|
|
||||||
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
||||||
|
|
||||||
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
const EnvelopeEditorPagePreviewRenderer = lazy(
|
||||||
|
async () => import('./envelope-editor-page-preview-renderer'),
|
||||||
|
);
|
||||||
|
|
||||||
export const EnvelopeEditorPreviewPage = () => {
|
export const EnvelopeEditorPagePreview = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
@ -49,7 +51,7 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorPagePreviewRenderer} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||||
@ -41,7 +41,7 @@ type LocalFile = {
|
|||||||
isError: boolean;
|
isError: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EnvelopeEditorUploadPage = () => {
|
export const EnvelopeEditorPageUpload = () => {
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -224,12 +224,8 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
<div className="mx-auto max-w-4xl space-y-6 p-8">
|
||||||
<Card backdropBlur={false} className="border">
|
<Card backdropBlur={false} className="border">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>
|
<CardTitle>Documents</CardTitle>
|
||||||
<Trans>Documents</Trans>
|
<CardDescription>Add and configure multiple documents</CardDescription>
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
<Trans>Add and configure multiple documents</Trans>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -39,10 +39,10 @@ import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-l
|
|||||||
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
|
|
||||||
import EnvelopeEditorHeader from './envelope-editor-header';
|
import EnvelopeEditorHeader from './envelope-editor-header';
|
||||||
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
|
import { EnvelopeEditorPageFields } from './envelope-editor-page-fields';
|
||||||
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
|
import { EnvelopeEditorPagePreview } from './envelope-editor-page-preview';
|
||||||
|
import { EnvelopeEditorPageUpload } from './envelope-editor-page-upload';
|
||||||
|
|
||||||
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
type EnvelopeEditorStep = 'upload' | 'addFields' | 'preview';
|
||||||
|
|
||||||
@ -128,18 +128,6 @@ export default function EnvelopeEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch the URL params and setStep if the step changes.
|
|
||||||
useEffect(() => {
|
|
||||||
const stepParam = searchParams.get('step') || envelopeEditorSteps[0].id;
|
|
||||||
|
|
||||||
const foundStep = envelopeEditorSteps.find((step) => step.id === stepParam);
|
|
||||||
|
|
||||||
if (foundStep && foundStep.id !== currentStep) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
navigateToStep(foundStep.id as EnvelopeEditorStep);
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAutosaving) {
|
if (!isAutosaving) {
|
||||||
setIsStepLoading(false);
|
setIsStepLoading(false);
|
||||||
@ -163,9 +151,7 @@ export default function EnvelopeEditor() {
|
|||||||
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
{isDocument ? <Trans>Document Editor</Trans> : <Trans>Template Editor</Trans>}
|
||||||
|
|
||||||
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
<span className="text-muted-foreground ml-2 rounded border bg-gray-50 px-2 py-0.5 text-xs">
|
||||||
<Trans context="The step counter">
|
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
||||||
Step {currentStepData.order}/{envelopeEditorSteps.length}
|
|
||||||
</Trans>
|
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -354,12 +340,13 @@ export default function EnvelopeEditor() {
|
|||||||
|
|
||||||
{/* Main Content - Changes based on current step */}
|
{/* Main Content - Changes based on current step */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<p>{isAutosaving ? 'Autosaving...' : 'Not autosaving'}</p>
|
||||||
<AnimateGenericFadeInOut key={currentStep}>
|
<AnimateGenericFadeInOut key={currentStep}>
|
||||||
{match({ currentStep, isStepLoading })
|
{match({ currentStep, isStepLoading })
|
||||||
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
.with({ isStepLoading: true }, () => <SpinnerBox className="py-32" />)
|
||||||
.with({ currentStep: 'upload' }, () => <EnvelopeEditorUploadPage />)
|
.with({ currentStep: 'upload' }, () => <EnvelopeEditorPageUpload />)
|
||||||
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorFieldsPage />)
|
.with({ currentStep: 'addFields' }, () => <EnvelopeEditorPageFields />)
|
||||||
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPreviewPage />)
|
.with({ currentStep: 'preview' }, () => <EnvelopeEditorPagePreview />)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</AnimateGenericFadeInOut>
|
</AnimateGenericFadeInOut>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,31 +1,41 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import type Konva from 'konva';
|
import Konva from 'konva';
|
||||||
|
import type { Layer } from 'konva/lib/Layer';
|
||||||
|
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { usePageContext } from 'react-pdf';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
|
|
||||||
export default function EnvelopeGenericPageRenderer() {
|
export default function EnvelopeGenericPageRenderer() {
|
||||||
const { t } = useLingui();
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
|
if (!pageContext) {
|
||||||
|
throw new Error('Unable to find Page context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _className, page, rotate, scale } = pageContext;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useLingui();
|
||||||
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const {
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
stage,
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
pageLayer,
|
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
|
||||||
unscaledViewport,
|
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => {
|
|
||||||
createPageCanvas(stage, pageLayer);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
|
const pageLayer = useRef<Layer | null>(null);
|
||||||
|
|
||||||
|
const viewport = useMemo(
|
||||||
|
() => page.getViewport({ scale, rotation: rotate }),
|
||||||
|
[page, rotate, scale],
|
||||||
|
);
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -36,6 +46,44 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
[fields, pageContext.pageNumber],
|
[fields, pageContext.pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Custom renderer from Konva examples.
|
||||||
|
useEffect(
|
||||||
|
function drawPageOnCanvas() {
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current: canvas } = canvasElement;
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (!canvas || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContext: RenderParameters = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellable = page.render(renderContext);
|
||||||
|
const runningTask = cancellable;
|
||||||
|
|
||||||
|
cancellable.promise.catch(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancellable.promise.then(() => {
|
||||||
|
createPageCanvas(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runningTask.cancel();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
@ -43,7 +91,6 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderField({
|
renderField({
|
||||||
scale,
|
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.id.toString(),
|
renderId: field.id.toString(),
|
||||||
@ -56,8 +103,8 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
inserted: false,
|
inserted: false,
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
},
|
},
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: viewport.width,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight: viewport.height,
|
||||||
// color: getRecipientColorKey(field.recipientId),
|
// color: getRecipientColorKey(field.recipientId),
|
||||||
color: 'purple', // Todo
|
color: 'purple', // Todo
|
||||||
editable: false,
|
editable: false,
|
||||||
@ -66,15 +113,25 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (_currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
const createPageCanvas = (container: HTMLDivElement) => {
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
stage.current?.add(pageLayer.current);
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -110,19 +167,14 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||||
className="relative w-full"
|
<div className="konva-container absolute inset-0 z-10" ref={konvaContainer}></div>
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
|
height={viewport.height}
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
height={scaledViewport.height}
|
width={viewport.width}
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { type Field, FieldType, type Signature } from '@prisma/client';
|
import { type Field, FieldType } from '@prisma/client';
|
||||||
import type Konva from 'konva';
|
import Konva from 'konva';
|
||||||
|
import type { Layer } from 'konva/lib/Layer';
|
||||||
import type { KonvaEventObject } from 'konva/lib/Node';
|
import type { KonvaEventObject } from 'konva/lib/Node';
|
||||||
|
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
||||||
|
import { usePageContext } from 'react-pdf';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||||
@ -26,6 +28,18 @@ import { handleTextFieldClick } from '~/utils/field-signing/text-field';
|
|||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
export default function EnvelopeSignerPageRenderer() {
|
export default function EnvelopeSignerPageRenderer() {
|
||||||
|
const pageContext = usePageContext();
|
||||||
|
|
||||||
|
if (!pageContext) {
|
||||||
|
throw new Error('Unable to find Page context.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { _className, page, rotate, scale } = pageContext;
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Attempted to render page canvas, but no page was specified.');
|
||||||
|
}
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
@ -44,20 +58,21 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
setSignature,
|
setSignature,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
const {
|
console.log({ fullName });
|
||||||
stage,
|
|
||||||
pageLayer,
|
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
|
||||||
pageContext,
|
|
||||||
scaledViewport,
|
|
||||||
unscaledViewport,
|
|
||||||
} = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer));
|
|
||||||
|
|
||||||
const { _className, scale } = pageContext;
|
|
||||||
|
|
||||||
const { envelope } = envelopeData;
|
const { envelope } = envelopeData;
|
||||||
|
|
||||||
|
const canvasElement = useRef<HTMLCanvasElement>(null);
|
||||||
|
const konvaContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const stage = useRef<Konva.Stage | null>(null);
|
||||||
|
const pageLayer = useRef<Layer | null>(null);
|
||||||
|
|
||||||
|
const viewport = useMemo(
|
||||||
|
() => page.getViewport({ scale, rotation: rotate }),
|
||||||
|
[page, rotate, scale],
|
||||||
|
);
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
recipientFields.filter(
|
recipientFields.filter(
|
||||||
@ -67,7 +82,45 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
[recipientFields, pageContext.pageNumber],
|
[recipientFields, pageContext.pageNumber],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
|
// Custom renderer from Konva examples.
|
||||||
|
useEffect(
|
||||||
|
function drawPageOnCanvas() {
|
||||||
|
if (!page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { current: canvas } = canvasElement;
|
||||||
|
const { current: container } = konvaContainer;
|
||||||
|
|
||||||
|
if (!canvas || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContext: RenderParameters = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
||||||
|
viewport,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancellable = page.render(renderContext);
|
||||||
|
const runningTask = cancellable;
|
||||||
|
|
||||||
|
cancellable.promise.catch(() => {
|
||||||
|
// Intentionally empty
|
||||||
|
});
|
||||||
|
|
||||||
|
void cancellable.promise.then(() => {
|
||||||
|
createPageCanvas(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
runningTask.cancel();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[page, viewport],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderFieldOnLayer = (unparsedField: Field) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
@ -84,7 +137,6 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { fieldGroup } = renderField({
|
const { fieldGroup } = renderField({
|
||||||
scale,
|
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: fieldToRender.id.toString(),
|
renderId: fieldToRender.id.toString(),
|
||||||
@ -93,10 +145,9 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
height: Number(fieldToRender.height),
|
height: Number(fieldToRender.height),
|
||||||
positionX: Number(fieldToRender.positionX),
|
positionX: Number(fieldToRender.positionX),
|
||||||
positionY: Number(fieldToRender.positionY),
|
positionY: Number(fieldToRender.positionY),
|
||||||
signature: unparsedField.signature,
|
|
||||||
},
|
},
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: viewport.width,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight: viewport.height,
|
||||||
color,
|
color,
|
||||||
mode: 'sign',
|
mode: 'sign',
|
||||||
});
|
});
|
||||||
@ -306,19 +357,29 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Konva page canvas and all fields and interactions.
|
* Create the initial Konva page canvas and initialize all fields and interactions.
|
||||||
*/
|
*/
|
||||||
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
|
const createPageCanvas = (container: HTMLDivElement) => {
|
||||||
|
stage.current = new Konva.Stage({
|
||||||
|
container,
|
||||||
|
width: viewport.width,
|
||||||
|
height: viewport.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the main layer for interactive elements.
|
||||||
|
pageLayer.current = new Konva.Layer();
|
||||||
|
stage.current?.add(pageLayer.current);
|
||||||
|
|
||||||
console.log({
|
console.log({
|
||||||
localPageFields,
|
localPageFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Render the fields.
|
// Render the fields.
|
||||||
for (const field of localPageFields) {
|
for (const field of localPageFields) {
|
||||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
renderFieldOnLayer(field);
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPageLayer.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -331,7 +392,7 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
|
|
||||||
localPageFields.forEach((field) => {
|
localPageFields.forEach((field) => {
|
||||||
console.log('Field changed/inserted, rendering on canvas');
|
console.log('Field changed/inserted, rendering on canvas');
|
||||||
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
|
renderFieldOnLayer(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
pageLayer.current.batchDraw();
|
pageLayer.current.batchDraw();
|
||||||
@ -342,19 +403,14 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative" key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}>
|
||||||
className="relative w-full"
|
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
|
||||||
>
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
{/* Canvas the PDF will be rendered on. */}
|
|
||||||
<canvas
|
<canvas
|
||||||
className={`${_className}__canvas z-0`}
|
className={`${_className}__canvas z-0`}
|
||||||
|
height={viewport.height}
|
||||||
ref={canvasElement}
|
ref={canvasElement}
|
||||||
height={scaledViewport.height}
|
width={viewport.width}
|
||||||
width={scaledViewport.width}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||||
@ -28,22 +29,15 @@ import { useCurrentTeam } from '~/providers/team';
|
|||||||
export type FolderCardProps = {
|
export type FolderCardProps = {
|
||||||
folder: TFolderWithSubfolders;
|
folder: TFolderWithSubfolders;
|
||||||
onMove: (folder: TFolderWithSubfolders) => void;
|
onMove: (folder: TFolderWithSubfolders) => void;
|
||||||
onPin: (folderId: string) => void;
|
|
||||||
onUnpin: (folderId: string) => void;
|
|
||||||
onSettings: (folder: TFolderWithSubfolders) => void;
|
onSettings: (folder: TFolderWithSubfolders) => void;
|
||||||
onDelete: (folder: TFolderWithSubfolders) => void;
|
onDelete: (folder: TFolderWithSubfolders) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FolderCard = ({
|
export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => {
|
||||||
folder,
|
|
||||||
onMove,
|
|
||||||
onPin,
|
|
||||||
onUnpin,
|
|
||||||
onSettings,
|
|
||||||
onDelete,
|
|
||||||
}: FolderCardProps) => {
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
|
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
|
||||||
|
|
||||||
const formatPath = () => {
|
const formatPath = () => {
|
||||||
const rootPath =
|
const rootPath =
|
||||||
folder.type === FolderType.DOCUMENT
|
folder.type === FolderType.DOCUMENT
|
||||||
@ -53,6 +47,15 @@ export const FolderCard = ({
|
|||||||
return `${rootPath}/f/${folder.id}`;
|
return `${rootPath}/f/${folder.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateFolder = async ({ pinned }: { pinned: boolean }) => {
|
||||||
|
await updateFolderMutation({
|
||||||
|
folderId: folder.id,
|
||||||
|
data: {
|
||||||
|
pinned,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
|
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
|
||||||
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
|
||||||
@ -112,9 +115,7 @@ export const FolderCard = ({
|
|||||||
<Trans>Move</Trans>
|
<Trans>Move</Trans>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
|
||||||
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
|
|
||||||
>
|
|
||||||
<PinIcon className="mr-2 h-4 w-4" />
|
<PinIcon className="mr-2 h-4 w-4" />
|
||||||
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
|
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@ -19,8 +19,6 @@ import { DocumentUploadButton } from '~/components/general/document/document-upl
|
|||||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
import { EnvelopeUploadButton } from '../document/envelope-upload-button';
|
|
||||||
|
|
||||||
export type FolderGridProps = {
|
export type FolderGridProps = {
|
||||||
type: FolderType;
|
type: FolderType;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
@ -36,9 +34,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||||
|
|
||||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
|
||||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
|
||||||
|
|
||||||
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
|
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
|
||||||
type,
|
type,
|
||||||
parentId,
|
parentId,
|
||||||
@ -100,7 +95,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
|
|
||||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||||
{/* Todo: Envelopes - Feature flag */}
|
{/* Todo: Envelopes - Feature flag */}
|
||||||
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
{/* <EnvelopeUploadButton type={type} folderId={parentId || undefined} /> */}
|
||||||
|
|
||||||
{type === FolderType.DOCUMENT ? (
|
{type === FolderType.DOCUMENT ? (
|
||||||
<DocumentUploadButton />
|
<DocumentUploadButton />
|
||||||
@ -157,8 +152,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
setFolderToMove(folder);
|
setFolderToMove(folder);
|
||||||
setIsMovingFolder(true);
|
setIsMovingFolder(true);
|
||||||
}}
|
}}
|
||||||
onPin={(folderId) => void pinFolder({ folderId })}
|
|
||||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
|
||||||
onSettings={(folder) => {
|
onSettings={(folder) => {
|
||||||
setFolderToSettings(folder);
|
setFolderToSettings(folder);
|
||||||
setIsSettingsFolderOpen(true);
|
setIsSettingsFolderOpen(true);
|
||||||
@ -182,8 +175,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
|||||||
setFolderToMove(folder);
|
setFolderToMove(folder);
|
||||||
setIsMovingFolder(true);
|
setIsMovingFolder(true);
|
||||||
}}
|
}}
|
||||||
onPin={(folderId) => void pinFolder({ folderId })}
|
|
||||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
|
||||||
onSettings={(folder) => {
|
onSettings={(folder) => {
|
||||||
setFolderToSettings(folder);
|
setFolderToSettings(folder);
|
||||||
setIsSettingsFolderOpen(true);
|
setIsSettingsFolderOpen(true);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|||||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
||||||
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
|
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||||
@ -122,11 +123,13 @@ export default function DocumentEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{document.useLegacyFieldInsertion && (
|
<div className="flex items-center gap-x-4">
|
||||||
<div>
|
<DocumentAttachmentsPopover envelopeId={document.envelopeId} />
|
||||||
|
|
||||||
|
{document.useLegacyFieldInsertion && (
|
||||||
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
<LegacyFieldWarningPopover type="document" documentId={document.id} />
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentEditForm
|
<DocumentEditForm
|
||||||
|
|||||||
@ -42,9 +42,6 @@ export default function DocumentsFoldersPage() {
|
|||||||
parentId: null,
|
parentId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
|
||||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
|
||||||
|
|
||||||
const navigateToFolder = (folderId?: string | null) => {
|
const navigateToFolder = (folderId?: string | null) => {
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
@ -113,8 +110,6 @@ export default function DocumentsFoldersPage() {
|
|||||||
setFolderToMove(folder);
|
setFolderToMove(folder);
|
||||||
setIsMovingFolder(true);
|
setIsMovingFolder(true);
|
||||||
}}
|
}}
|
||||||
onPin={(folderId) => void pinFolder({ folderId })}
|
|
||||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
|
||||||
onSettings={(folder) => {
|
onSettings={(folder) => {
|
||||||
setFolderToSettings(folder);
|
setFolderToSettings(folder);
|
||||||
setIsSettingsFolderOpen(true);
|
setIsSettingsFolderOpen(true);
|
||||||
@ -147,8 +142,6 @@ export default function DocumentsFoldersPage() {
|
|||||||
setFolderToMove(folder);
|
setFolderToMove(folder);
|
||||||
setIsMovingFolder(true);
|
setIsMovingFolder(true);
|
||||||
}}
|
}}
|
||||||
onPin={(folderId) => void pinFolder({ folderId })}
|
|
||||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
|
||||||
onSettings={(folder) => {
|
onSettings={(folder) => {
|
||||||
setFolderToSettings(folder);
|
setFolderToSettings(folder);
|
||||||
setIsSettingsFolderOpen(true);
|
setIsSettingsFolderOpen(true);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
|
|||||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||||
|
|
||||||
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
|
||||||
|
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
|
||||||
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
|
||||||
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
|
||||||
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
|
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
|
||||||
@ -87,6 +88,8 @@ export default function TemplateEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
|
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
|
||||||
|
<DocumentAttachmentsPopover envelopeId={template.envelopeId} />
|
||||||
|
|
||||||
<TemplateDirectLinkDialog
|
<TemplateDirectLinkDialog
|
||||||
templateId={template.id}
|
templateId={template.id}
|
||||||
directLink={template.directLink}
|
directLink={template.directLink}
|
||||||
|
|||||||
@ -42,9 +42,6 @@ export default function TemplatesFoldersPage() {
|
|||||||
parentId: null,
|
parentId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
|
||||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
|
||||||
|
|
||||||
const navigateToFolder = (folderId?: string | null) => {
|
const navigateToFolder = (folderId?: string | null) => {
|
||||||
const templatesPath = formatTemplatesPath(team.url);
|
const templatesPath = formatTemplatesPath(team.url);
|
||||||
|
|
||||||
@ -113,8 +110,6 @@ export default function TemplatesFoldersPage() {
|
|||||||
setFolderToMove(folder);
|
setFolderToMove(folder);
|
||||||
setIsMovingFolder(true);
|
setIsMovingFolder(true);
|
||||||
}}
|
}}
|
||||||
onPin={(folderId) => void pinFolder({ folderId })}
|
|
||||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
|
||||||
onSettings={(folder) => {
|
onSettings={(folder) => {
|
||||||
setFolderToSettings(folder);
|
setFolderToSettings(folder);
|
||||||
setIsSettingsFolderOpen(true);
|
setIsSettingsFolderOpen(true);
|
||||||
@ -147,8 +142,6 @@ export default function TemplatesFoldersPage() {
|
|||||||
setFolderToMove(folder);
|
setFolderToMove(folder);
|
||||||
setIsMovingFolder(true);
|
setIsMovingFolder(true);
|
||||||
}}
|
}}
|
||||||
onPin={(folderId) => void pinFolder({ folderId })}
|
|
||||||
onUnpin={(folderId) => void unpinFolder({ folderId })}
|
|
||||||
onSettings={(folder) => {
|
onSettings={(folder) => {
|
||||||
setFolderToSettings(folder);
|
setFolderToSettings(folder);
|
||||||
setIsSettingsFolderOpen(true);
|
setIsSettingsFolderOpen(true);
|
||||||
|
|||||||
@ -95,6 +95,7 @@ export default function DirectTemplatePage() {
|
|||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={directTemplateRecipient}
|
recipient={directTemplateRecipient}
|
||||||
|
isDirectTemplate={true}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
import {
|
import {
|
||||||
IS_GOOGLE_SSO_ENABLED,
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
|
IS_MICROSOFT_SSO_ENABLED,
|
||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -23,22 +27,45 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||||
|
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
|
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||||
|
|
||||||
|
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
throw redirect('/');
|
throw redirect(returnTo || '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
|
returnTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
|
const {
|
||||||
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
|
isOIDCSSOEnabled,
|
||||||
|
oidcProviderLabel,
|
||||||
|
returnTo,
|
||||||
|
} = loaderData;
|
||||||
|
|
||||||
|
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
|
||||||
|
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-screen max-w-lg px-4">
|
||||||
@ -54,15 +81,20 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
|||||||
|
|
||||||
<SignInForm
|
<SignInForm
|
||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
oidcProviderLabel={oidcProviderLabel}
|
||||||
|
returnTo={returnTo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
|
<Link
|
||||||
|
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
|
||||||
|
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||||
|
>
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { redirect } from 'react-router';
|
import { redirect } from 'react-router';
|
||||||
|
|
||||||
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
|
import {
|
||||||
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
|
IS_MICROSOFT_SSO_ENABLED,
|
||||||
|
IS_OIDC_SSO_ENABLED,
|
||||||
|
} from '@documenso/lib/constants/auth';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -12,31 +17,40 @@ export function meta() {
|
|||||||
return appMetaTags('Sign Up');
|
return appMetaTags('Sign Up');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loader() {
|
export function loader({ request }: Route.LoaderArgs) {
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||||
|
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
|
|
||||||
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||||
throw redirect('/signin');
|
throw redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||||
|
|
||||||
|
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
|
returnTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled } = loaderData;
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SignUpForm
|
<SignUpForm
|
||||||
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
|
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
|
||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
|
returnTo={returnTo}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
IS_GOOGLE_SSO_ENABLED,
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
|
IS_MICROSOFT_SSO_ENABLED,
|
||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
@ -29,11 +30,13 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
|
|||||||
export function loader() {
|
export function loader() {
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||||
|
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
};
|
};
|
||||||
@ -44,7 +47,8 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||||
|
loaderData || {};
|
||||||
|
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
|
|
||||||
@ -53,6 +57,7 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
|||||||
return (
|
return (
|
||||||
<EmbedAuthenticationRequired
|
<EmbedAuthenticationRequired
|
||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
oidcProviderLabel={oidcProviderLabel}
|
||||||
email={error.data.email}
|
email={error.data.email}
|
||||||
|
|||||||
@ -69,7 +69,6 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
throw data(
|
throw data(
|
||||||
{
|
{
|
||||||
type: 'embed-authentication-required',
|
type: 'embed-authentication-required',
|
||||||
email: user?.email,
|
|
||||||
returnTo: `/embed/direct/${token}`,
|
returnTo: `/embed/direct/${token}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -117,11 +116,13 @@ export default function EmbedDirectTemplatePage() {
|
|||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
|
isDirectTemplate={true}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||||
<EmbedDirectTemplateClientPage
|
<EmbedDirectTemplateClientPage
|
||||||
token={token}
|
token={token}
|
||||||
|
envelopeId={template.envelopeId}
|
||||||
updatedAt={template.updatedAt}
|
updatedAt={template.updatedAt}
|
||||||
documentData={template.templateDocumentData}
|
documentData={template.templateDocumentData}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
|
|||||||
@ -164,6 +164,7 @@ export default function EmbedSignDocumentPage() {
|
|||||||
<EmbedSignDocumentClientPage
|
<EmbedSignDocumentClientPage
|
||||||
token={token}
|
token={token}
|
||||||
documentId={document.id}
|
documentId={document.id}
|
||||||
|
envelopeId={document.envelopeId}
|
||||||
documentData={document.documentData}
|
documentData={document.documentData}
|
||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { ZBaseEmbedDataSchema } from './embed-base-schemas';
|
|||||||
|
|
||||||
export const ZBaseEmbedAuthoringSchema = z
|
export const ZBaseEmbedAuthoringSchema = z
|
||||||
.object({
|
.object({
|
||||||
token: z.string(),
|
|
||||||
externalId: z.string().optional(),
|
externalId: z.string().optional(),
|
||||||
features: z
|
features: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -103,5 +103,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "1.12.10"
|
"version": "1.13.2"
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/remix/public/static/microsoft.svg
Normal file
1
apps/remix/public/static/microsoft.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#F1511B" d="M121.666 121.666H0V0h121.666z"/><path fill="#80CC28" d="M256 121.666H134.335V0H256z"/><path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z"/><path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z"/></svg>
|
||||||
|
After Width: | Height: | Size: 356 B |
@ -1,6 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { rateLimiter } from 'hono-rate-limiter';
|
import { rateLimiter } from 'hono-rate-limiter';
|
||||||
import { contextStorage } from 'hono/context-storage';
|
import { contextStorage } from 'hono/context-storage';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
import { requestId } from 'hono/request-id';
|
import { requestId } from 'hono/request-id';
|
||||||
import type { RequestIdVariables } from 'hono/request-id';
|
import type { RequestIdVariables } from 'hono/request-id';
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
@ -83,12 +84,14 @@ app.route('/api/auth', auth);
|
|||||||
app.route('/api/files', filesRoute);
|
app.route('/api/files', filesRoute);
|
||||||
|
|
||||||
// API servers.
|
// API servers.
|
||||||
|
app.use(`/api/v1/*`, cors());
|
||||||
app.route('/api/v1', tsRestHonoApp);
|
app.route('/api/v1', tsRestHonoApp);
|
||||||
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
app.use('/api/jobs/*', jobsClient.getApiHandler());
|
||||||
app.use('/api/trpc/*', reactRouterTrpcServer);
|
app.use('/api/trpc/*', reactRouterTrpcServer);
|
||||||
|
|
||||||
// Unstable API server routes. Order matters for these two.
|
// Unstable API server routes. Order matters for these two.
|
||||||
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||||
|
app.use(`${API_V2_BETA_URL}/*`, cors());
|
||||||
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
app.use(`${API_V2_BETA_URL}/*`, async (c) => openApiTrpcServerHandler(c));
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.12.10",
|
"version": "1.13.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.12.10",
|
"version": "1.13.2",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@ -89,7 +89,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "1.12.10",
|
"version": "1.13.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.3.2",
|
"@cantoo/pdf-lib": "^2.3.2",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.12.10",
|
"version": "1.13.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
|
|||||||
@ -427,6 +427,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
globalAccessAuth: body.authOptions?.globalAccessAuth,
|
globalAccessAuth: body.authOptions?.globalAccessAuth,
|
||||||
globalActionAuth: body.authOptions?.globalActionAuth,
|
globalActionAuth: body.authOptions?.globalActionAuth,
|
||||||
},
|
},
|
||||||
|
attachments: body.attachments,
|
||||||
meta: {
|
meta: {
|
||||||
subject: body.meta.subject,
|
subject: body.meta.subject,
|
||||||
message: body.meta.message,
|
message: body.meta.message,
|
||||||
@ -497,6 +498,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
publicDescription,
|
publicDescription,
|
||||||
type,
|
type,
|
||||||
meta,
|
meta,
|
||||||
|
attachments,
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -568,6 +570,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
publicDescription,
|
publicDescription,
|
||||||
},
|
},
|
||||||
meta,
|
meta,
|
||||||
|
attachments,
|
||||||
requestMetadata: metadata,
|
requestMetadata: metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -792,6 +795,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
...body.meta,
|
...body.meta,
|
||||||
title: body.title,
|
title: body.title,
|
||||||
},
|
},
|
||||||
|
attachments: body.attachments,
|
||||||
requestMetadata: metadata,
|
requestMetadata: metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
ZRecipientActionAuthTypesSchema,
|
ZRecipientActionAuthTypesSchema,
|
||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
|
||||||
|
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
|
||||||
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
|
||||||
extendZodWithOpenApi(z);
|
extendZodWithOpenApi(z);
|
||||||
@ -197,6 +198,15 @@ export const ZCreateDocumentMutationSchema = z.object({
|
|||||||
description: 'The globalActionAuth property is only available for Enterprise accounts.',
|
description: 'The globalActionAuth property is only available for Enterprise accounts.',
|
||||||
}),
|
}),
|
||||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||||
|
attachments: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
label: z.string().min(1, 'Label is required'),
|
||||||
|
data: z.string().url('Must be a valid URL'),
|
||||||
|
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
|
||||||
@ -262,6 +272,15 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
|
||||||
|
attachments: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
label: z.string().min(1, 'Label is required'),
|
||||||
|
data: z.string().url('Must be a valid URL'),
|
||||||
|
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
export type TCreateDocumentFromTemplateMutationSchema = z.infer<
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import {
|
|||||||
seedDraftDocument,
|
seedDraftDocument,
|
||||||
seedPendingDocument,
|
seedPendingDocument,
|
||||||
} from '@documenso/prisma/seed/documents';
|
} from '@documenso/prisma/seed/documents';
|
||||||
|
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
@ -326,11 +327,6 @@ test.describe('Document API V2', () => {
|
|||||||
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
|
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const asdf = await res.json();
|
|
||||||
console.log({
|
|
||||||
asdf,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
});
|
});
|
||||||
@ -407,11 +403,6 @@ test.describe('Document API V2', () => {
|
|||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
});
|
});
|
||||||
|
|
||||||
const asdf = await res.json();
|
|
||||||
console.log({
|
|
||||||
asdf,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
});
|
});
|
||||||
@ -2715,4 +2706,154 @@ test.describe('Document API V2', () => {
|
|||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Folder list endpoint', () => {
|
||||||
|
test('should block unauthorized access to folder list endpoint', async ({ request }) => {
|
||||||
|
await seedBlankFolder(userA, teamA.id);
|
||||||
|
await seedBlankFolder(userA, teamA.id);
|
||||||
|
|
||||||
|
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenB}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
|
||||||
|
const { data } = await res.json();
|
||||||
|
expect(data.every((folder: { userId: number }) => folder.userId !== userA.id)).toBe(true);
|
||||||
|
expect(data.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow authorized access to folder list endpoint', async ({ request }) => {
|
||||||
|
await seedBlankFolder(userA, teamA.id);
|
||||||
|
await seedBlankFolder(userA, teamA.id);
|
||||||
|
|
||||||
|
// Other team folders should not be visible.
|
||||||
|
await seedBlankFolder(userA, teamB.id);
|
||||||
|
await seedBlankFolder(userA, teamB.id);
|
||||||
|
|
||||||
|
// Other team and user folders should not be visible.
|
||||||
|
await seedBlankFolder(userB, teamB.id);
|
||||||
|
await seedBlankFolder(userB, teamB.id);
|
||||||
|
|
||||||
|
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
|
||||||
|
const { data } = await res.json();
|
||||||
|
|
||||||
|
expect(data.length).toBe(2);
|
||||||
|
expect(data.every((folder: { userId: number }) => folder.userId === userA.id)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Folder create endpoint', () => {
|
||||||
|
test('should block unauthorized access to folder create endpoint', async ({ request }) => {
|
||||||
|
const unauthorizedFolder = await seedBlankFolder(userB, teamB.id);
|
||||||
|
|
||||||
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: {
|
||||||
|
parentId: unauthorizedFolder.id,
|
||||||
|
name: 'Test Folder',
|
||||||
|
type: 'DOCUMENT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow authorized access to folder create endpoint', async ({ request }) => {
|
||||||
|
const authorizedFolder = await seedBlankFolder(userA, teamA.id);
|
||||||
|
|
||||||
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: {
|
||||||
|
parentId: authorizedFolder.id,
|
||||||
|
name: 'Test Folder',
|
||||||
|
type: 'DOCUMENT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
|
||||||
|
const noParentRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: {
|
||||||
|
name: 'Test Folder',
|
||||||
|
type: 'DOCUMENT',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(noParentRes.ok()).toBeTruthy();
|
||||||
|
expect(noParentRes.status()).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Folder update endpoint', () => {
|
||||||
|
test('should block unauthorized access to folder update endpoint', async ({ request }) => {
|
||||||
|
const folder = await seedBlankFolder(userA, teamA.id);
|
||||||
|
|
||||||
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenB}` },
|
||||||
|
data: {
|
||||||
|
folderId: folder.id,
|
||||||
|
data: {
|
||||||
|
name: 'Updated Folder Name',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow authorized access to folder update endpoint', async ({ request }) => {
|
||||||
|
const folder = await seedBlankFolder(userA, teamA.id);
|
||||||
|
|
||||||
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: {
|
||||||
|
folderId: folder.id,
|
||||||
|
data: {
|
||||||
|
name: 'Updated Folder Name',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Folder delete endpoint', () => {
|
||||||
|
test('should block unauthorized access to folder delete endpoint', async ({ request }) => {
|
||||||
|
const folder = await seedBlankFolder(userA, teamA.id);
|
||||||
|
|
||||||
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenB}` },
|
||||||
|
data: { folderId: folder.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow authorized access to folder delete endpoint', async ({ request }) => {
|
||||||
|
const folder = await seedBlankFolder(userA, teamA.id);
|
||||||
|
|
||||||
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
|
||||||
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
|
data: { folderId: folder.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -222,6 +222,22 @@ export class AuthClient {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public microsoft = {
|
||||||
|
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||||
|
const response = await this.client['oauth'].authorize.microsoft.$post({
|
||||||
|
json: { redirectPath },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.handleError(response);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.redirectUrl) {
|
||||||
|
window.location.href = data.redirectUrl;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
public oidc = {
|
public oidc = {
|
||||||
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
|
||||||
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });
|
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });
|
||||||
|
|||||||
@ -26,6 +26,16 @@ export const GoogleAuthOptions: OAuthClientOptions = {
|
|||||||
bypassEmailVerification: false,
|
bypassEmailVerification: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MicrosoftAuthOptions: OAuthClientOptions = {
|
||||||
|
id: 'microsoft',
|
||||||
|
scope: ['openid', 'email', 'profile'],
|
||||||
|
clientId: env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') ?? '',
|
||||||
|
clientSecret: env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET') ?? '',
|
||||||
|
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/microsoft`,
|
||||||
|
wellKnownUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
|
||||||
|
bypassEmailVerification: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const OidcAuthOptions: OAuthClientOptions = {
|
export const OidcAuthOptions: OAuthClientOptions = {
|
||||||
id: 'oidc',
|
id: 'oidc',
|
||||||
scope: ['openid', 'email', 'profile'],
|
scope: ['openid', 'email', 'profile'],
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { deleteCookie } from 'hono/cookie';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||||
|
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import type { OAuthClientOptions } from '../../config';
|
import type { OAuthClientOptions } from '../../config';
|
||||||
@ -177,6 +178,12 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
|||||||
redirectPath = '/';
|
redirectPath = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidReturnTo(redirectPath)) {
|
||||||
|
redirectPath = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
||||||
|
|
||||||
const tokens = await oAuthClient.validateAuthorizationCode(
|
const tokens = await oAuthClient.validateAuthorizationCode(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
code,
|
code,
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|||||||
|
|
||||||
import { AppError } from '@documenso/lib/errors/app-error';
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
|
||||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
|
||||||
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
|
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
|
||||||
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
|
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
|
||||||
import type { HonoAuthContext } from '../types/context';
|
import type { HonoAuthContext } from '../types/context';
|
||||||
@ -45,4 +45,11 @@ export const callbackRoute = new Hono<HonoAuthContext>()
|
|||||||
/**
|
/**
|
||||||
* Google callback verification.
|
* Google callback verification.
|
||||||
*/
|
*/
|
||||||
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }));
|
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Microsoft callback verification.
|
||||||
|
*/
|
||||||
|
.get('/microsoft', async (c) =>
|
||||||
|
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
|
||||||
|
);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { sValidator } from '@hono/standard-validator';
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
|
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
|
||||||
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
|
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
|
||||||
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
|
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
|
||||||
import type { HonoAuthContext } from '../types/context';
|
import type { HonoAuthContext } from '../types/context';
|
||||||
@ -24,6 +24,20 @@ export const oauthRoute = new Hono<HonoAuthContext>()
|
|||||||
redirectPath,
|
redirectPath,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Microsoft authorize endpoint.
|
||||||
|
*/
|
||||||
|
.post('/authorize/microsoft', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
|
||||||
|
const { redirectPath } = c.req.valid('json');
|
||||||
|
|
||||||
|
return handleOAuthAuthorizeUrl({
|
||||||
|
c,
|
||||||
|
clientOptions: MicrosoftAuthOptions,
|
||||||
|
redirectPath,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC authorize endpoint.
|
* OIDC authorize endpoint.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -136,7 +136,6 @@ export const useEditorFields = ({
|
|||||||
const field: TLocalField = {
|
const field: TLocalField = {
|
||||||
...fieldData,
|
...fieldData,
|
||||||
formId: nanoid(12),
|
formId: nanoid(12),
|
||||||
...restrictFieldPosValues(fieldData),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
append(field);
|
append(field);
|
||||||
@ -166,15 +165,7 @@ export const useEditorFields = ({
|
|||||||
const index = localFields.findIndex((field) => field.formId === formId);
|
const index = localFields.findIndex((field) => field.formId === formId);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const updatedField = {
|
update(index, { ...localFields[index], ...updates });
|
||||||
...localFields[index],
|
|
||||||
...updates,
|
|
||||||
};
|
|
||||||
|
|
||||||
update(index, {
|
|
||||||
...updatedField,
|
|
||||||
...restrictFieldPosValues(updatedField),
|
|
||||||
});
|
|
||||||
triggerFieldsUpdate();
|
triggerFieldsUpdate();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -288,14 +279,3 @@ export const useEditorFields = ({
|
|||||||
setSelectedRecipient,
|
setSelectedRecipient,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const restrictFieldPosValues = (
|
|
||||||
field: Pick<TLocalField, 'positionX' | 'positionY' | 'width' | 'height'>,
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
positionX: Math.max(0, Math.min(100, field.positionX)),
|
|
||||||
positionY: Math.max(0, Math.min(100, field.positionY)),
|
|
||||||
width: Math.max(0, Math.min(100, field.width)),
|
|
||||||
height: Math.max(0, Math.min(100, field.height)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,105 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
|
||||||
|
|
||||||
import Konva from 'konva';
|
|
||||||
import type { RenderParameters } from 'pdfjs-dist/types/src/display/api';
|
|
||||||
import { usePageContext } from 'react-pdf';
|
|
||||||
|
|
||||||
type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void;
|
|
||||||
|
|
||||||
export function usePageRenderer(renderFunction: RenderFunction) {
|
|
||||||
const pageContext = usePageContext();
|
|
||||||
|
|
||||||
if (!pageContext) {
|
|
||||||
throw new Error('Unable to find Page context.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { page, rotate, scale } = pageContext;
|
|
||||||
|
|
||||||
if (!page) {
|
|
||||||
throw new Error('Attempted to render page canvas, but no page was specified.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvasElement = useRef<HTMLCanvasElement>(null);
|
|
||||||
const konvaContainer = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const stage = useRef<Konva.Stage | null>(null);
|
|
||||||
const pageLayer = useRef<Konva.Layer | null>(null);
|
|
||||||
|
|
||||||
const unscaledViewport = useMemo(
|
|
||||||
() => page.getViewport({ scale: 1, rotation: rotate }),
|
|
||||||
[page, rotate, scale],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scaledViewport = useMemo(
|
|
||||||
() => page.getViewport({ scale, rotation: rotate }),
|
|
||||||
[page, rotate, scale],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the PDF and create the scaled Konva stage.
|
|
||||||
*/
|
|
||||||
useEffect(
|
|
||||||
function drawPageOnCanvas() {
|
|
||||||
if (!page) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { current: canvas } = canvasElement;
|
|
||||||
const { current: kContainer } = konvaContainer;
|
|
||||||
|
|
||||||
if (!canvas || !kContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderContext: RenderParameters = {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
||||||
canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D,
|
|
||||||
viewport: scaledViewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancellable = page.render(renderContext);
|
|
||||||
const runningTask = cancellable;
|
|
||||||
|
|
||||||
cancellable.promise.catch(() => {
|
|
||||||
// Intentionally empty
|
|
||||||
});
|
|
||||||
|
|
||||||
void cancellable.promise.then(() => {
|
|
||||||
stage.current = new Konva.Stage({
|
|
||||||
container: kContainer,
|
|
||||||
width: scaledViewport.width,
|
|
||||||
height: scaledViewport.height,
|
|
||||||
scale: {
|
|
||||||
x: scale,
|
|
||||||
y: scale,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create the main layer for interactive elements.
|
|
||||||
pageLayer.current = new Konva.Layer();
|
|
||||||
|
|
||||||
stage.current.add(pageLayer.current);
|
|
||||||
|
|
||||||
renderFunction({
|
|
||||||
stage: stage.current,
|
|
||||||
pageLayer: pageLayer.current,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
runningTask.cancel();
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[page, scaledViewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
canvasElement,
|
|
||||||
konvaContainer,
|
|
||||||
stage,
|
|
||||||
pageLayer,
|
|
||||||
unscaledViewport,
|
|
||||||
scaledViewport,
|
|
||||||
pageContext,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -135,12 +135,7 @@ export const EnvelopeEditorProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||||
onSuccess: ({ recipients }) => {
|
onSuccess: () => {
|
||||||
setEnvelope((prev) => ({
|
|
||||||
...prev,
|
|
||||||
recipients,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setAutosaveError(false);
|
setAutosaveError(false);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -220,15 +215,14 @@ export const EnvelopeEditorProvider = ({
|
|||||||
|
|
||||||
const getRecipientColorKey = useCallback(
|
const getRecipientColorKey = useCallback(
|
||||||
(recipientId: number) => {
|
(recipientId: number) => {
|
||||||
|
// Todo: Envelopes - Local recipients
|
||||||
const recipientIndex = envelope.recipients.findIndex(
|
const recipientIndex = envelope.recipients.findIndex(
|
||||||
(recipient) => recipient.id === recipientId,
|
(recipient) => recipient.id === recipientId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return AVAILABLE_RECIPIENT_COLORS[
|
return AVAILABLE_RECIPIENT_COLORS[Math.max(recipientIndex, 0)];
|
||||||
Math.max(recipientIndex, 0) % AVAILABLE_RECIPIENT_COLORS.length
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
[envelope.recipients],
|
[envelope.recipients], // Todo: Envelopes - Local recipients
|
||||||
);
|
);
|
||||||
|
|
||||||
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
const { refetch: reloadEnvelope, isLoading: isReloadingEnvelope } = trpc.envelope.get.useQuery(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export const SALT_ROUNDS = 12;
|
|||||||
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
|
||||||
DOCUMENSO: 'Documenso',
|
DOCUMENSO: 'Documenso',
|
||||||
GOOGLE: 'Google',
|
GOOGLE: 'Google',
|
||||||
|
MICROSOFT: 'Microsoft',
|
||||||
OIDC: 'OIDC',
|
OIDC: 'OIDC',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -13,6 +14,10 @@ export const IS_GOOGLE_SSO_ENABLED = Boolean(
|
|||||||
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
|
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IS_MICROSOFT_SSO_ENABLED = Boolean(
|
||||||
|
env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') && env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET'),
|
||||||
|
);
|
||||||
|
|
||||||
export const IS_OIDC_SSO_ENABLED = Boolean(
|
export const IS_OIDC_SSO_ENABLED = Boolean(
|
||||||
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
|
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
|
||||||
env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&
|
env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&
|
||||||
|
|||||||
@ -81,12 +81,17 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
|
|||||||
const { user: owner } = envelope;
|
const { user: owner } = envelope;
|
||||||
|
|
||||||
const completedDocumentEmailAttachments = await Promise.all(
|
const completedDocumentEmailAttachments = await Promise.all(
|
||||||
envelope.envelopeItems.map(async (document) => {
|
envelope.envelopeItems.map(async (envelopeItem) => {
|
||||||
const file = await getFileServerSide(document.documentData);
|
const file = await getFileServerSide(envelopeItem.documentData);
|
||||||
|
|
||||||
|
// Use the envelope title for version 1, and the envelope item title for version 2.
|
||||||
|
const fileNameToUse =
|
||||||
|
envelope.internalVersion === 1 ? envelope.title : envelopeItem.title + '.pdf';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
|
filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
|
||||||
content: Buffer.from(file),
|
content: Buffer.from(file),
|
||||||
|
contentType: 'application/pdf',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -56,7 +56,6 @@ export const sendDocument = async ({
|
|||||||
recipients: {
|
recipients: {
|
||||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||||
},
|
},
|
||||||
fields: true,
|
|
||||||
documentMeta: true,
|
documentMeta: true,
|
||||||
envelopeItems: {
|
envelopeItems: {
|
||||||
select: {
|
select: {
|
||||||
@ -166,16 +165,6 @@ export const sendDocument = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldsToAutoInsert = [];
|
|
||||||
|
|
||||||
// Todo: Envelopes - Handle auto-signing
|
|
||||||
if (envelope.internalVersion === 2) {
|
|
||||||
// fieldsToAutoInsert = envelope.fields.filter((field) => !field.inserted);
|
|
||||||
// if (fieldsToAutoInsert.length > 0) {
|
|
||||||
// //
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||||
if (envelope.status === DocumentStatus.DRAFT) {
|
if (envelope.status === DocumentStatus.DRAFT) {
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
|
|||||||
@ -0,0 +1,50 @@
|
|||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
|
||||||
|
export type CreateAttachmentOptions = {
|
||||||
|
envelopeId: string;
|
||||||
|
teamId: number;
|
||||||
|
userId: number;
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
data: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createAttachment = async ({
|
||||||
|
envelopeId,
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
data,
|
||||||
|
}: CreateAttachmentOptions) => {
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelopeId,
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.status === DocumentStatus.COMPLETED || envelope.status === DocumentStatus.REJECTED) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.envelopeAttachment.create({
|
||||||
|
data: {
|
||||||
|
envelopeId,
|
||||||
|
type: 'link',
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
|
||||||
|
export type DeleteAttachmentOptions = {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentOptions) => {
|
||||||
|
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
envelope: {
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
envelope: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Attachment not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||||
|
attachment.envelope.status === DocumentStatus.REJECTED
|
||||||
|
) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.envelopeAttachment.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
|
||||||
|
export type FindAttachmentsByEnvelopeIdOptions = {
|
||||||
|
envelopeId: string;
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAttachmentsByEnvelopeId = async ({
|
||||||
|
envelopeId,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
}: FindAttachmentsByEnvelopeIdOptions) => {
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelopeId,
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.envelopeAttachment.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type FindAttachmentsByTokenOptions = {
|
||||||
|
envelopeId: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAttachmentsByToken = async ({
|
||||||
|
envelopeId,
|
||||||
|
token,
|
||||||
|
}: FindAttachmentsByTokenOptions) => {
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelopeId,
|
||||||
|
recipients: {
|
||||||
|
some: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.envelopeAttachment.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FindAttachmentsByTeamOptions = {
|
||||||
|
envelopeId: string;
|
||||||
|
teamId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAttachmentsByTeam = async ({
|
||||||
|
envelopeId,
|
||||||
|
teamId,
|
||||||
|
}: FindAttachmentsByTeamOptions) => {
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelopeId,
|
||||||
|
teamId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.envelopeAttachment.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { DocumentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
|
||||||
|
export type UpdateAttachmentOptions = {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
data: { label?: string; data?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttachmentOptions) => {
|
||||||
|
const attachment = await prisma.envelopeAttachment.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
envelope: {
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
envelope: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Attachment not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
attachment.envelope.status === DocumentStatus.COMPLETED ||
|
||||||
|
attachment.envelope.status === DocumentStatus.REJECTED
|
||||||
|
) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Attachments can not be modified after the document has been completed or rejected',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.envelopeAttachment.update({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -20,6 +20,7 @@ import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-rou
|
|||||||
|
|
||||||
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
|
||||||
import type { TDocumentFormValues } from '../../types/document-form-values';
|
import type { TDocumentFormValues } from '../../types/document-form-values';
|
||||||
|
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
|
||||||
import {
|
import {
|
||||||
ZWebhookDocumentSchema,
|
ZWebhookDocumentSchema,
|
||||||
mapEnvelopeToWebhookDocumentPayload,
|
mapEnvelopeToWebhookDocumentPayload,
|
||||||
@ -58,6 +59,11 @@ export type CreateEnvelopeOptions = {
|
|||||||
recipients?: TCreateEnvelopeRequest['recipients'];
|
recipients?: TCreateEnvelopeRequest['recipients'];
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
};
|
};
|
||||||
|
attachments?: Array<{
|
||||||
|
label: string;
|
||||||
|
data: string;
|
||||||
|
type?: TEnvelopeAttachmentType;
|
||||||
|
}>;
|
||||||
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
meta?: Partial<Omit<DocumentMeta, 'id'>>;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@ -67,6 +73,7 @@ export const createEnvelope = async ({
|
|||||||
teamId,
|
teamId,
|
||||||
normalizePdf,
|
normalizePdf,
|
||||||
data,
|
data,
|
||||||
|
attachments,
|
||||||
meta,
|
meta,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
internalVersion,
|
internalVersion,
|
||||||
@ -246,6 +253,15 @@ export const createEnvelope = async ({
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
envelopeAttachments: {
|
||||||
|
createMany: {
|
||||||
|
data: (attachments || []).map((attachment) => ({
|
||||||
|
label: attachment.label,
|
||||||
|
data: attachment.data,
|
||||||
|
type: attachment.type ?? 'link',
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
authOptions,
|
authOptions,
|
||||||
@ -338,6 +354,7 @@ export const createEnvelope = async ({
|
|||||||
fields: true,
|
fields: true,
|
||||||
folder: true,
|
folder: true,
|
||||||
envelopeItems: true,
|
envelopeItems: true,
|
||||||
|
envelopeAttachments: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -156,11 +156,9 @@ export const setFieldsForDocument = async ({
|
|||||||
|
|
||||||
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
if (field.type === FieldType.NUMBER && field.fieldMeta) {
|
||||||
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
|
||||||
|
|
||||||
const errors = validateNumberField(
|
const errors = validateNumberField(
|
||||||
String(numberFieldParsedMeta.value),
|
String(numberFieldParsedMeta.value),
|
||||||
numberFieldParsedMeta,
|
numberFieldParsedMeta,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import type { TFolderType } from '../../types/folder-type';
|
import type { TFolderType } from '../../types/folder-type';
|
||||||
import { FolderType } from '../../types/folder-type';
|
import { FolderType } from '../../types/folder-type';
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
|
|
||||||
export interface CreateFolderOptions {
|
export interface CreateFolderOptions {
|
||||||
@ -22,6 +24,27 @@ export const createFolder = async ({
|
|||||||
// This indirectly verifies whether the user has access to the team.
|
// This indirectly verifies whether the user has access to the team.
|
||||||
const settings = await getTeamSettings({ userId, teamId });
|
const settings = await getTeamSettings({ userId, teamId });
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
const parentFolder = await prisma.folder.findFirst({
|
||||||
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parentFolder) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Parent folder not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentFolder.type !== type) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: 'Parent folder type does not match the folder type',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await prisma.folder.create({
|
return await prisma.folder.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||||
import { getTeamById } from '../team/get-team';
|
import { getTeamById } from '../team/get-team';
|
||||||
|
|
||||||
@ -20,6 +21,9 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
|
|||||||
teamId,
|
teamId,
|
||||||
userId,
|
userId,
|
||||||
}),
|
}),
|
||||||
|
visibility: {
|
||||||
|
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -39,7 +43,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
|
|||||||
|
|
||||||
return await prisma.folder.delete({
|
return await prisma.folder.delete({
|
||||||
where: {
|
where: {
|
||||||
id: folderId,
|
id: folder.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
117
packages/lib/server-only/folder/find-folders-internal.ts
Normal file
117
packages/lib/server-only/folder/find-folders-internal.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||||
|
import type { TFolderType } from '../../types/folder-type';
|
||||||
|
import { getTeamById } from '../team/get-team';
|
||||||
|
|
||||||
|
export interface FindFoldersInternalOptions {
|
||||||
|
userId: number;
|
||||||
|
teamId: number;
|
||||||
|
parentId?: string | null;
|
||||||
|
type?: TFolderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findFoldersInternal = async ({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
parentId,
|
||||||
|
type,
|
||||||
|
}: FindFoldersInternalOptions) => {
|
||||||
|
const team = await getTeamById({ userId, teamId });
|
||||||
|
|
||||||
|
const visibilityFilters = {
|
||||||
|
visibility: {
|
||||||
|
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const whereClause = {
|
||||||
|
AND: [
|
||||||
|
{ parentId },
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ teamId, ...visibilityFilters },
|
||||||
|
{ userId, teamId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folders = await prisma.folder.findMany({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
...(type ? { type } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const foldersWithDetails = await Promise.all(
|
||||||
|
folders.map(async (folder) => {
|
||||||
|
try {
|
||||||
|
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
|
||||||
|
prisma.folder.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: folder.id,
|
||||||
|
teamId,
|
||||||
|
...visibilityFilters,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.envelope.count({
|
||||||
|
where: {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
folderId: folder.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.envelope.count({
|
||||||
|
where: {
|
||||||
|
type: EnvelopeType.TEMPLATE,
|
||||||
|
folderId: folder.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.folder.count({
|
||||||
|
where: {
|
||||||
|
parentId: folder.id,
|
||||||
|
teamId,
|
||||||
|
...visibilityFilters,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
|
||||||
|
...subfolder,
|
||||||
|
subfolders: [],
|
||||||
|
_count: {
|
||||||
|
documents: 0,
|
||||||
|
templates: 0,
|
||||||
|
subfolders: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...folder,
|
||||||
|
subfolders: subfoldersWithEmptySubfolders,
|
||||||
|
_count: {
|
||||||
|
documents: documentCount,
|
||||||
|
templates: templateCount,
|
||||||
|
subfolders: subfolderCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing folder:', folder.id, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return foldersWithDetails;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in findFolders:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,9 +1,11 @@
|
|||||||
import { EnvelopeType } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||||
import type { TFolderType } from '../../types/folder-type';
|
import type { TFolderType } from '../../types/folder-type';
|
||||||
|
import type { FindResultResponse } from '../../types/search-params';
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { getTeamById } from '../team/get-team';
|
import { getTeamById } from '../team/get-team';
|
||||||
|
|
||||||
export interface FindFoldersOptions {
|
export interface FindFoldersOptions {
|
||||||
@ -11,102 +13,48 @@ export interface FindFoldersOptions {
|
|||||||
teamId: number;
|
teamId: number;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
type?: TFolderType;
|
type?: TFolderType;
|
||||||
|
page?: number;
|
||||||
|
perPage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
|
export const findFolders = async ({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
parentId,
|
||||||
|
type,
|
||||||
|
page = 1,
|
||||||
|
perPage = 10,
|
||||||
|
}: FindFoldersOptions) => {
|
||||||
const team = await getTeamById({ userId, teamId });
|
const team = await getTeamById({ userId, teamId });
|
||||||
|
|
||||||
const visibilityFilters = {
|
const whereClause: Prisma.FolderWhereInput = {
|
||||||
|
parentId,
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
type,
|
||||||
visibility: {
|
visibility: {
|
||||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const whereClause = {
|
const [data, count] = await Promise.all([
|
||||||
AND: [
|
prisma.folder.findMany({
|
||||||
{ parentId },
|
where: whereClause,
|
||||||
{
|
skip: Math.max(page - 1, 0) * perPage,
|
||||||
OR: [
|
take: perPage,
|
||||||
{ teamId, ...visibilityFilters },
|
orderBy: {
|
||||||
{ userId, teamId },
|
createdAt: 'desc',
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
}),
|
||||||
};
|
prisma.folder.count({
|
||||||
|
where: whereClause,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
return {
|
||||||
const folders = await prisma.folder.findMany({
|
data,
|
||||||
where: {
|
count,
|
||||||
...whereClause,
|
currentPage: Math.max(page, 1),
|
||||||
...(type ? { type } : {}),
|
perPage,
|
||||||
},
|
totalPages: Math.ceil(count / perPage),
|
||||||
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
|
} satisfies FindResultResponse<typeof data>;
|
||||||
});
|
|
||||||
|
|
||||||
const foldersWithDetails = await Promise.all(
|
|
||||||
folders.map(async (folder) => {
|
|
||||||
try {
|
|
||||||
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
|
|
||||||
prisma.folder.findMany({
|
|
||||||
where: {
|
|
||||||
parentId: folder.id,
|
|
||||||
teamId,
|
|
||||||
...visibilityFilters,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.envelope.count({
|
|
||||||
where: {
|
|
||||||
type: EnvelopeType.DOCUMENT,
|
|
||||||
folderId: folder.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.envelope.count({
|
|
||||||
where: {
|
|
||||||
type: EnvelopeType.TEMPLATE,
|
|
||||||
folderId: folder.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.folder.count({
|
|
||||||
where: {
|
|
||||||
parentId: folder.id,
|
|
||||||
teamId,
|
|
||||||
...visibilityFilters,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
|
|
||||||
...subfolder,
|
|
||||||
subfolders: [],
|
|
||||||
_count: {
|
|
||||||
documents: 0,
|
|
||||||
templates: 0,
|
|
||||||
subfolders: 0,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...folder,
|
|
||||||
subfolders: subfoldersWithEmptySubfolders,
|
|
||||||
_count: {
|
|
||||||
documents: documentCount,
|
|
||||||
templates: templateCount,
|
|
||||||
subfolders: subfolderCount,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error processing folder:', folder.id, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return foldersWithDetails;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in findFolders:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,51 +1,30 @@
|
|||||||
import { TeamMemberRole } from '@prisma/client';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import { DocumentVisibility } from '../../types/document-visibility';
|
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||||
import type { TFolderType } from '../../types/folder-type';
|
import type { TFolderType } from '../../types/folder-type';
|
||||||
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { getTeamById } from '../team/get-team';
|
import { getTeamById } from '../team/get-team';
|
||||||
|
|
||||||
export interface GetFolderByIdOptions {
|
export interface GetFolderByIdOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
folderId?: string;
|
folderId: string;
|
||||||
type?: TFolderType;
|
type?: TFolderType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
|
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
|
||||||
const team = await getTeamById({ userId, teamId });
|
const team = await getTeamById({ userId, teamId });
|
||||||
|
|
||||||
const visibilityFilters = match(team.currentTeamRole)
|
|
||||||
.with(TeamMemberRole.ADMIN, () => ({
|
|
||||||
visibility: {
|
|
||||||
in: [
|
|
||||||
DocumentVisibility.EVERYONE,
|
|
||||||
DocumentVisibility.MANAGER_AND_ABOVE,
|
|
||||||
DocumentVisibility.ADMIN,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
.with(TeamMemberRole.MANAGER, () => ({
|
|
||||||
visibility: {
|
|
||||||
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
|
|
||||||
|
|
||||||
const whereClause = {
|
|
||||||
id: folderId,
|
|
||||||
...(type ? { type } : {}),
|
|
||||||
OR: [
|
|
||||||
{ teamId, ...visibilityFilters },
|
|
||||||
{ userId, teamId },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const folder = await prisma.folder.findFirst({
|
const folder = await prisma.folder.findFirst({
|
||||||
where: whereClause,
|
where: {
|
||||||
|
id: folderId,
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
type,
|
||||||
|
visibility: {
|
||||||
|
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!folder) {
|
if (!folder) {
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
|
||||||
|
|
||||||
export interface MoveFolderOptions {
|
|
||||||
userId: number;
|
|
||||||
teamId?: number;
|
|
||||||
folderId?: string;
|
|
||||||
parentId?: string | null;
|
|
||||||
requestMetadata?: ApiRequestMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
|
|
||||||
return await prisma.$transaction(async (tx) => {
|
|
||||||
const folder = await tx.folder.findFirst({
|
|
||||||
where: {
|
|
||||||
id: folderId,
|
|
||||||
team: buildTeamWhereQuery({
|
|
||||||
teamId,
|
|
||||||
userId,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!folder) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Folder not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
const parentFolder = await tx.folder.findFirst({
|
|
||||||
where: {
|
|
||||||
id: parentId,
|
|
||||||
userId,
|
|
||||||
teamId,
|
|
||||||
type: folder.type,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!parentFolder) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Parent folder not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parentId === folderId) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
||||||
message: 'Cannot move a folder into itself',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentParentId = parentFolder.parentId;
|
|
||||||
while (currentParentId) {
|
|
||||||
if (currentParentId === folderId) {
|
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
|
||||||
message: 'Cannot move a folder into its descendant',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentParent = await tx.folder.findUnique({
|
|
||||||
where: {
|
|
||||||
id: currentParentId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
parentId: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentParent) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentParentId = currentParent.parentId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return await tx.folder.update({
|
|
||||||
where: {
|
|
||||||
id: folderId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
parentId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import type { TFolderType } from '../../types/folder-type';
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
|
||||||
|
|
||||||
export interface PinFolderOptions {
|
|
||||||
userId: number;
|
|
||||||
teamId?: number;
|
|
||||||
folderId: string;
|
|
||||||
type?: TFolderType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
|
|
||||||
const folder = await prisma.folder.findFirst({
|
|
||||||
where: {
|
|
||||||
id: folderId,
|
|
||||||
team: buildTeamWhereQuery({
|
|
||||||
teamId,
|
|
||||||
userId,
|
|
||||||
}),
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!folder) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Folder not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return await prisma.folder.update({
|
|
||||||
where: {
|
|
||||||
id: folderId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
pinned: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
import type { TFolderType } from '../../types/folder-type';
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
|
||||||
|
|
||||||
export interface UnpinFolderOptions {
|
|
||||||
userId: number;
|
|
||||||
teamId?: number;
|
|
||||||
folderId: string;
|
|
||||||
type?: TFolderType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
|
|
||||||
const folder = await prisma.folder.findFirst({
|
|
||||||
where: {
|
|
||||||
id: folderId,
|
|
||||||
team: buildTeamWhereQuery({
|
|
||||||
teamId,
|
|
||||||
userId,
|
|
||||||
}),
|
|
||||||
type,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!folder) {
|
|
||||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
|
||||||
message: 'Folder not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return await prisma.folder.update({
|
|
||||||
where: {
|
|
||||||
id: folderId,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
pinned: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,28 +1,28 @@
|
|||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { DocumentVisibility } from '@documenso/prisma/generated/types';
|
import type { DocumentVisibility } from '@documenso/prisma/generated/types';
|
||||||
|
|
||||||
import type { TFolderType } from '../../types/folder-type';
|
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||||
import { FolderType } from '../../types/folder-type';
|
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
|
import { getTeamById } from '../team/get-team';
|
||||||
|
|
||||||
export interface UpdateFolderOptions {
|
export interface UpdateFolderOptions {
|
||||||
userId: number;
|
userId: number;
|
||||||
teamId?: number;
|
teamId: number;
|
||||||
folderId: string;
|
folderId: string;
|
||||||
name: string;
|
data: {
|
||||||
visibility: DocumentVisibility;
|
parentId?: string | null;
|
||||||
type?: TFolderType;
|
name?: string;
|
||||||
|
visibility?: DocumentVisibility;
|
||||||
|
pinned?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateFolder = async ({
|
export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => {
|
||||||
userId,
|
const { parentId, name, visibility, pinned } = data;
|
||||||
teamId,
|
|
||||||
folderId,
|
const team = await getTeamById({ userId, teamId });
|
||||||
name,
|
|
||||||
visibility,
|
|
||||||
type,
|
|
||||||
}: UpdateFolderOptions) => {
|
|
||||||
const folder = await prisma.folder.findFirst({
|
const folder = await prisma.folder.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: folderId,
|
id: folderId,
|
||||||
@ -30,7 +30,9 @@ export const updateFolder = async ({
|
|||||||
teamId,
|
teamId,
|
||||||
userId,
|
userId,
|
||||||
}),
|
}),
|
||||||
type,
|
visibility: {
|
||||||
|
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,17 +42,66 @@ export const updateFolder = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
|
if (parentId) {
|
||||||
const effectiveVisibility =
|
const parentFolder = await prisma.folder.findFirst({
|
||||||
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
team: buildTeamWhereQuery({ teamId, userId }),
|
||||||
|
type: folder.type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parentFolder) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Parent folder not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId === folderId) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Cannot move a folder into itself',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentParentId = parentFolder.parentId;
|
||||||
|
|
||||||
|
while (currentParentId) {
|
||||||
|
if (currentParentId === folderId) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Cannot move a folder into its descendant',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentParent = await prisma.folder.findUnique({
|
||||||
|
where: {
|
||||||
|
id: currentParentId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
parentId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentParent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentParentId = currentParent.parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await prisma.folder.update({
|
return await prisma.folder.update({
|
||||||
where: {
|
where: {
|
||||||
id: folderId,
|
id: folderId,
|
||||||
|
team: buildTeamWhereQuery({
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
visibility: effectiveVisibility,
|
visibility,
|
||||||
|
parentId,
|
||||||
|
pinned,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { RotationTypes, radiansToDegrees } from '@cantoo/pdf-lib';
|
|||||||
import fontkit from '@pdf-lib/fontkit';
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import 'konva/skia-backend';
|
import 'konva/skia-backend';
|
||||||
|
import fs from 'node:fs';
|
||||||
import type { Canvas } from 'skia-canvas';
|
import type { Canvas } from 'skia-canvas';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -85,7 +86,6 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
|
|||||||
|
|
||||||
// Will render onto the layer.
|
// Will render onto the layer.
|
||||||
renderField({
|
renderField({
|
||||||
scale: 1,
|
|
||||||
field: {
|
field: {
|
||||||
renderId: field.id.toString(),
|
renderId: field.id.toString(),
|
||||||
...field,
|
...field,
|
||||||
@ -105,10 +105,10 @@ export const insertFieldInPDFV2 = async (pdf: PDFDocument, field: FieldWithSigna
|
|||||||
|
|
||||||
const renderedField = await canvas.toBuffer('svg');
|
const renderedField = await canvas.toBuffer('svg');
|
||||||
|
|
||||||
// fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
// `rendered-field-${field.envelopeId}--${field.id}.svg`,
|
`rendered-field-${field.envelopeId}--${field.id}.svg`,
|
||||||
// renderedField.toString('utf-8'),
|
renderedField.toString('utf-8'),
|
||||||
// );
|
);
|
||||||
|
|
||||||
// Embed the SVG into the PDF
|
// Embed the SVG into the PDF
|
||||||
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
|
const svgElement = await pdf.embedSvg(renderedField.toString('utf-8'));
|
||||||
|
|||||||
@ -640,6 +640,23 @@ export const createDocumentFromDirectTemplate = async ({
|
|||||||
data: auditLogsToCreate,
|
data: auditLogsToCreate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const templateAttachments = await tx.envelopeAttachment.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: directTemplateEnvelope.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (templateAttachments.length > 0) {
|
||||||
|
await tx.envelopeAttachment.createMany({
|
||||||
|
data: templateAttachments.map((attachment) => ({
|
||||||
|
envelopeId: createdEnvelope.id,
|
||||||
|
type: attachment.type,
|
||||||
|
label: attachment.label,
|
||||||
|
data: attachment.data,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Send email to template owner.
|
// Send email to template owner.
|
||||||
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
|
||||||
recipientName: directRecipientEmail,
|
recipientName: directRecipientEmail,
|
||||||
|
|||||||
@ -91,6 +91,12 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
envelopeItemId?: string;
|
envelopeItemId?: string;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
|
attachments?: Array<{
|
||||||
|
label: string;
|
||||||
|
data: string;
|
||||||
|
type?: 'link';
|
||||||
|
}>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Values that will override the predefined values in the template.
|
* Values that will override the predefined values in the template.
|
||||||
*/
|
*/
|
||||||
@ -295,6 +301,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
requestMetadata,
|
requestMetadata,
|
||||||
folderId,
|
folderId,
|
||||||
prefillFields,
|
prefillFields,
|
||||||
|
attachments,
|
||||||
}: CreateDocumentFromTemplateOptions) => {
|
}: CreateDocumentFromTemplateOptions) => {
|
||||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
id,
|
id,
|
||||||
@ -667,6 +674,33 @@ export const createDocumentFromTemplate = async ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const templateAttachments = await tx.envelopeAttachment.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: template.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachmentsToCreate = [
|
||||||
|
...templateAttachments.map((attachment) => ({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
type: attachment.type,
|
||||||
|
label: attachment.label,
|
||||||
|
data: attachment.data,
|
||||||
|
})),
|
||||||
|
...(attachments || []).map((attachment) => ({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
type: attachment.type || 'link',
|
||||||
|
label: attachment.label,
|
||||||
|
data: attachment.data,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (attachmentsToCreate.length > 0) {
|
||||||
|
await tx.envelopeAttachment.createMany({
|
||||||
|
data: attachmentsToCreate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const createdEnvelope = await tx.envelope.findFirst({
|
const createdEnvelope = await tx.envelope.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: envelope.id,
|
id: envelope.id,
|
||||||
|
|||||||
@ -1,22 +1,23 @@
|
|||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { kyselyPrisma, sql } from '@documenso/prisma';
|
import { kyselyPrisma, sql } from '@documenso/prisma';
|
||||||
|
|
||||||
export const getCompletedDocumentsMonthly = async () => {
|
export const getCompletedDocumentsMonthly = async () => {
|
||||||
const qb = kyselyPrisma.$kysely
|
const qb = kyselyPrisma.$kysely
|
||||||
.selectFrom('Document')
|
.selectFrom('Envelope')
|
||||||
.select(({ fn }) => [
|
.select(({ fn }) => [
|
||||||
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
|
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
|
||||||
fn.count('id').as('count'),
|
fn.count('id').as('count'),
|
||||||
fn
|
fn
|
||||||
.sum(fn.count('id'))
|
.sum(fn.count('id'))
|
||||||
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||||
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
|
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
|
||||||
.as('cume_count'),
|
.as('cume_count'),
|
||||||
])
|
])
|
||||||
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
|
||||||
|
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
|
||||||
.groupBy('month')
|
.groupBy('month')
|
||||||
.orderBy('month', 'desc')
|
.orderBy('month', 'desc')
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
|||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
emailId: z.string().nullish(),
|
emailId: z.string().nullish(),
|
||||||
emailReplyTo: z.string().email().nullish(),
|
emailReplyTo: z.string().email().nullish(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
|
||||||
|
|||||||
5
packages/lib/types/envelope-attachment.ts
Normal file
5
packages/lib/types/envelope-attachment.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZEnvelopeAttachmentTypeSchema = z.enum(['link']);
|
||||||
|
|
||||||
|
export type TEnvelopeAttachmentType = z.infer<typeof ZEnvelopeAttachmentTypeSchema>;
|
||||||
@ -81,7 +81,6 @@ export const ZRadioFieldMeta = ZBaseFieldMeta.extend({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
direction: z.enum(['vertical', 'horizontal']).optional().default('vertical'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
|
export type TRadioFieldMeta = z.infer<typeof ZRadioFieldMeta>;
|
||||||
@ -279,7 +278,6 @@ export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
|
|||||||
values: [{ id: 1, checked: false, value: '' }],
|
values: [{ id: 1, checked: false, value: '' }],
|
||||||
required: false,
|
required: false,
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
direction: 'vertical',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
|
export const FIELD_CHECKBOX_META_DEFAULT_VALUES: TCheckboxFieldMeta = {
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export const upsertFieldGroup = (
|
|||||||
field: FieldToRender,
|
field: FieldToRender,
|
||||||
options: RenderFieldElementOptions,
|
options: RenderFieldElementOptions,
|
||||||
): Konva.Group => {
|
): Konva.Group => {
|
||||||
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
|
const { pageWidth, pageHeight, pageLayer, editable } = options;
|
||||||
|
|
||||||
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
|
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
|
||||||
field,
|
field,
|
||||||
@ -27,9 +27,6 @@ export const upsertFieldGroup = (
|
|||||||
name: 'field-group',
|
name: 'field-group',
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxXPosition = (pageWidth - fieldWidth) * scale;
|
|
||||||
const maxYPosition = (pageHeight - fieldHeight) * scale;
|
|
||||||
|
|
||||||
fieldGroup.setAttrs({
|
fieldGroup.setAttrs({
|
||||||
scaleX: 1,
|
scaleX: 1,
|
||||||
scaleY: 1,
|
scaleY: 1,
|
||||||
@ -37,9 +34,8 @@ export const upsertFieldGroup = (
|
|||||||
y: fieldY,
|
y: fieldY,
|
||||||
draggable: editable,
|
draggable: editable,
|
||||||
dragBoundFunc: (pos) => {
|
dragBoundFunc: (pos) => {
|
||||||
const newX = Math.max(0, Math.min(maxXPosition, pos.x));
|
const newX = Math.max(0, Math.min(pageWidth - fieldWidth, pos.x));
|
||||||
const newY = Math.max(0, Math.min(maxYPosition, pos.y));
|
const newY = Math.max(0, Math.min(pageHeight - fieldHeight, pos.y));
|
||||||
|
|
||||||
return { x: newX, y: newY };
|
return { x: newX, y: newY };
|
||||||
},
|
},
|
||||||
} satisfies Partial<Konva.GroupConfig>);
|
} satisfies Partial<Konva.GroupConfig>);
|
||||||
|
|||||||
@ -26,9 +26,8 @@ export type RenderFieldElementOptions = {
|
|||||||
pageLayer: Konva.Layer;
|
pageLayer: Konva.Layer;
|
||||||
pageWidth: number;
|
pageWidth: number;
|
||||||
pageHeight: number;
|
pageHeight: number;
|
||||||
mode: 'edit' | 'sign' | 'export';
|
mode?: 'edit' | 'sign' | 'export';
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
scale: number;
|
|
||||||
color?: TRecipientColor;
|
color?: TRecipientColor;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -108,11 +107,6 @@ type CalculateMultiItemPositionOptions = {
|
|||||||
*/
|
*/
|
||||||
fieldPadding: number;
|
fieldPadding: number;
|
||||||
|
|
||||||
/**
|
|
||||||
* The direction of the items.
|
|
||||||
*/
|
|
||||||
direction: 'horizontal' | 'vertical';
|
|
||||||
|
|
||||||
type: 'checkbox' | 'radio';
|
type: 'checkbox' | 'radio';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -128,7 +122,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
|||||||
itemSize,
|
itemSize,
|
||||||
spacingBetweenItemAndText,
|
spacingBetweenItemAndText,
|
||||||
fieldPadding,
|
fieldPadding,
|
||||||
direction,
|
|
||||||
type,
|
type,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
@ -137,39 +130,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
|||||||
const innerFieldX = fieldPadding;
|
const innerFieldX = fieldPadding;
|
||||||
const innerFieldY = fieldPadding;
|
const innerFieldY = fieldPadding;
|
||||||
|
|
||||||
if (direction === 'horizontal') {
|
|
||||||
const itemHeight = innerFieldHeight;
|
|
||||||
const itemWidth = innerFieldWidth / itemCount;
|
|
||||||
|
|
||||||
const y = innerFieldY;
|
|
||||||
const x = itemIndex * itemWidth + innerFieldX;
|
|
||||||
|
|
||||||
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
|
||||||
let itemInputX = x;
|
|
||||||
|
|
||||||
// We need a little different logic to center the radio circle icon.
|
|
||||||
if (type === 'radio') {
|
|
||||||
itemInputX = x + itemSize / 2;
|
|
||||||
itemInputY = y + itemHeight / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textX = x + itemSize + spacingBetweenItemAndText;
|
|
||||||
const textY = y;
|
|
||||||
|
|
||||||
// Multiplied by 2 for extra padding on the right hand side of the text and the next item.
|
|
||||||
const textWidth = itemWidth - itemSize - spacingBetweenItemAndText * 2;
|
|
||||||
const textHeight = itemHeight;
|
|
||||||
|
|
||||||
return {
|
|
||||||
itemInputX,
|
|
||||||
itemInputY,
|
|
||||||
textX,
|
|
||||||
textY,
|
|
||||||
textWidth,
|
|
||||||
textHeight,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemHeight = innerFieldHeight / itemCount;
|
const itemHeight = innerFieldHeight / itemCount;
|
||||||
|
|
||||||
const y = itemIndex * itemHeight + innerFieldY;
|
const y = itemIndex * itemHeight + innerFieldY;
|
||||||
@ -177,7 +137,6 @@ export const calculateMultiItemPosition = (options: CalculateMultiItemPositionOp
|
|||||||
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
let itemInputY = y + itemHeight / 2 - itemSize / 2;
|
||||||
let itemInputX = innerFieldX;
|
let itemInputX = innerFieldX;
|
||||||
|
|
||||||
// We need a little different logic to center the radio circle icon.
|
|
||||||
if (type === 'radio') {
|
if (type === 'radio') {
|
||||||
itemInputX = innerFieldX + itemSize / 2;
|
itemInputX = innerFieldX + itemSize / 2;
|
||||||
itemInputY = y + itemHeight / 2;
|
itemInputY = y + itemHeight / 2;
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||||
import type { TCheckboxFieldMeta } from '../../types/field-meta';
|
import type { TCheckboxFieldMeta } from '../../types/field-meta';
|
||||||
@ -22,112 +21,104 @@ export const renderCheckboxFieldElement = (
|
|||||||
|
|
||||||
const fieldGroup = upsertFieldGroup(field, options);
|
const fieldGroup = upsertFieldGroup(field, options);
|
||||||
|
|
||||||
// Clear previous children and listeners to re-render fresh.
|
// Clear previous children to re-render fresh
|
||||||
fieldGroup.removeChildren();
|
fieldGroup.removeChildren();
|
||||||
fieldGroup.off('transform');
|
|
||||||
|
|
||||||
fieldGroup.add(upsertFieldRect(field, options));
|
fieldGroup.add(upsertFieldRect(field, options));
|
||||||
|
|
||||||
|
if (isFirstRender) {
|
||||||
|
pageLayer.add(fieldGroup);
|
||||||
|
|
||||||
|
// Handle rescaling items during transforms.
|
||||||
|
fieldGroup.on('transform', () => {
|
||||||
|
const groupScaleX = fieldGroup.scaleX();
|
||||||
|
const groupScaleY = fieldGroup.scaleY();
|
||||||
|
|
||||||
|
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||||
|
|
||||||
|
if (!fieldRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rectWidth = fieldRect.width() * groupScaleX;
|
||||||
|
const rectHeight = fieldRect.height() * groupScaleY;
|
||||||
|
|
||||||
|
// Todo: Envelopes - check sorting more than 10
|
||||||
|
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||||
|
|
||||||
|
const squares = fieldGroup
|
||||||
|
.find('.checkbox-square')
|
||||||
|
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
const checkmarks = fieldGroup
|
||||||
|
.find('.checkbox-checkmark')
|
||||||
|
.sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
|
||||||
|
const groupedItems = squares.map((square, i) => ({
|
||||||
|
squareElement: square,
|
||||||
|
checkmarkElement: checkmarks[i],
|
||||||
|
textElement: text[i],
|
||||||
|
}));
|
||||||
|
|
||||||
|
groupedItems.forEach((item, i) => {
|
||||||
|
const { squareElement, checkmarkElement, textElement } = item;
|
||||||
|
|
||||||
|
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||||
|
calculateMultiItemPosition({
|
||||||
|
fieldWidth: rectWidth,
|
||||||
|
fieldHeight: rectHeight,
|
||||||
|
itemCount: checkboxValues.length,
|
||||||
|
itemIndex: i,
|
||||||
|
itemSize: checkboxSize,
|
||||||
|
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||||
|
fieldPadding: checkboxFieldPadding,
|
||||||
|
type: 'checkbox',
|
||||||
|
});
|
||||||
|
|
||||||
|
squareElement.setAttrs({
|
||||||
|
x: itemInputX,
|
||||||
|
y: itemInputY,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
checkmarkElement.setAttrs({
|
||||||
|
x: itemInputX,
|
||||||
|
y: itemInputY,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
textElement.setAttrs({
|
||||||
|
x: textX,
|
||||||
|
y: textY,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
width: textWidth,
|
||||||
|
height: textHeight,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldRect.setAttrs({
|
||||||
|
width: rectWidth,
|
||||||
|
height: rectHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
fieldGroup.scale({
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
pageLayer.batchDraw();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
|
const checkboxMeta: TCheckboxFieldMeta | null = (field.fieldMeta as TCheckboxFieldMeta) || null;
|
||||||
const checkboxValues = checkboxMeta?.values || [];
|
const checkboxValues = checkboxMeta?.values || [];
|
||||||
|
|
||||||
if (isFirstRender) {
|
|
||||||
pageLayer.add(fieldGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle rescaling items during transforms.
|
|
||||||
fieldGroup.on('transform', () => {
|
|
||||||
const groupScaleX = fieldGroup.scaleX();
|
|
||||||
const groupScaleY = fieldGroup.scaleY();
|
|
||||||
|
|
||||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
|
||||||
|
|
||||||
if (!fieldRect) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rectWidth = fieldRect.width() * groupScaleX;
|
|
||||||
const rectHeight = fieldRect.height() * groupScaleY;
|
|
||||||
|
|
||||||
// Todo: Envelopes - check sorting more than 10
|
|
||||||
// arr.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
||||||
|
|
||||||
const squares = fieldGroup
|
|
||||||
.find('.checkbox-square')
|
|
||||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
|
||||||
const checkmarks = fieldGroup
|
|
||||||
.find('.checkbox-checkmark')
|
|
||||||
.sort((a, b) => a.id().localeCompare(b.id()));
|
|
||||||
const text = fieldGroup.find('.checkbox-text').sort((a, b) => a.id().localeCompare(b.id()));
|
|
||||||
|
|
||||||
const groupedItems = squares.map((square, i) => ({
|
|
||||||
squareElement: square,
|
|
||||||
checkmarkElement: checkmarks[i],
|
|
||||||
textElement: text[i],
|
|
||||||
}));
|
|
||||||
|
|
||||||
groupedItems.forEach((item, i) => {
|
|
||||||
const { squareElement, checkmarkElement, textElement } = item;
|
|
||||||
|
|
||||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
|
||||||
calculateMultiItemPosition({
|
|
||||||
fieldWidth: rectWidth,
|
|
||||||
fieldHeight: rectHeight,
|
|
||||||
itemCount: checkboxValues.length,
|
|
||||||
itemIndex: i,
|
|
||||||
itemSize: checkboxSize,
|
|
||||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
|
||||||
fieldPadding: checkboxFieldPadding,
|
|
||||||
direction: checkboxMeta?.direction || 'vertical',
|
|
||||||
type: 'checkbox',
|
|
||||||
});
|
|
||||||
|
|
||||||
squareElement.setAttrs({
|
|
||||||
x: itemInputX,
|
|
||||||
y: itemInputY,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
checkmarkElement.setAttrs({
|
|
||||||
x: itemInputX,
|
|
||||||
y: itemInputY,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
textElement.setAttrs({
|
|
||||||
x: textX,
|
|
||||||
y: textY,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
width: textWidth,
|
|
||||||
height: textHeight,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
fieldRect.setAttrs({
|
|
||||||
width: rectWidth,
|
|
||||||
height: rectHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
fieldGroup.scale({
|
|
||||||
x: 1,
|
|
||||||
y: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
pageLayer.batchDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||||
|
|
||||||
checkboxValues.forEach(({ id, value, checked }, index) => {
|
checkboxValues.forEach(({ id, value, checked }, index) => {
|
||||||
const isCheckboxChecked = match(mode)
|
|
||||||
.with('edit', () => checked)
|
|
||||||
.with('sign', () => value === field.customText)
|
|
||||||
.with('export', () => value === field.customText)
|
|
||||||
.exhaustive();
|
|
||||||
|
|
||||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||||
calculateMultiItemPosition({
|
calculateMultiItemPosition({
|
||||||
fieldWidth,
|
fieldWidth,
|
||||||
@ -137,7 +128,6 @@ export const renderCheckboxFieldElement = (
|
|||||||
itemSize: checkboxSize,
|
itemSize: checkboxSize,
|
||||||
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
spacingBetweenItemAndText: spacingBetweenCheckboxAndText,
|
||||||
fieldPadding: checkboxFieldPadding,
|
fieldPadding: checkboxFieldPadding,
|
||||||
direction: checkboxMeta?.direction || 'vertical',
|
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -166,7 +156,7 @@ export const renderCheckboxFieldElement = (
|
|||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
stroke: '#111827',
|
stroke: '#111827',
|
||||||
points: [3, 8, 7, 12, 13, 4],
|
points: [3, 8, 7, 12, 13, 4],
|
||||||
visible: isCheckboxChecked,
|
visible: checked,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = new Konva.Text({
|
const text = new Konva.Text({
|
||||||
|
|||||||
@ -47,7 +47,6 @@ type RenderFieldOptions = {
|
|||||||
*/
|
*/
|
||||||
mode: 'edit' | 'sign' | 'export';
|
mode: 'edit' | 'sign' | 'export';
|
||||||
|
|
||||||
scale: number;
|
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -57,7 +56,6 @@ export const renderField = ({
|
|||||||
pageWidth,
|
pageWidth,
|
||||||
pageHeight,
|
pageHeight,
|
||||||
mode,
|
mode,
|
||||||
scale,
|
|
||||||
editable,
|
editable,
|
||||||
color,
|
color,
|
||||||
}: RenderFieldOptions) => {
|
}: RenderFieldOptions) => {
|
||||||
@ -68,7 +66,6 @@ export const renderField = ({
|
|||||||
mode,
|
mode,
|
||||||
color,
|
color,
|
||||||
editable,
|
editable,
|
||||||
scale,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return match(field.type)
|
return match(field.type)
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import Konva from 'konva';
|
import Konva from 'konva';
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
import { DEFAULT_STANDARD_FONT_SIZE } from '../../constants/pdf';
|
||||||
import type { TRadioFieldMeta } from '../../types/field-meta';
|
import type { TRadioFieldMeta } from '../../types/field-meta';
|
||||||
@ -27,99 +26,90 @@ export const renderRadioFieldElement = (
|
|||||||
|
|
||||||
fieldGroup.add(upsertFieldRect(field, options));
|
fieldGroup.add(upsertFieldRect(field, options));
|
||||||
|
|
||||||
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
|
|
||||||
const radioValues = radioMeta?.values || [];
|
|
||||||
|
|
||||||
if (isFirstRender) {
|
if (isFirstRender) {
|
||||||
pageLayer.add(fieldGroup);
|
pageLayer.add(fieldGroup);
|
||||||
}
|
|
||||||
|
|
||||||
fieldGroup.off('transform');
|
// Handle rescaling items during transforms.
|
||||||
|
fieldGroup.on('transform', () => {
|
||||||
|
const groupScaleX = fieldGroup.scaleX();
|
||||||
|
const groupScaleY = fieldGroup.scaleY();
|
||||||
|
|
||||||
// Handle rescaling items during transforms.
|
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||||
fieldGroup.on('transform', () => {
|
|
||||||
const groupScaleX = fieldGroup.scaleX();
|
|
||||||
const groupScaleY = fieldGroup.scaleY();
|
|
||||||
|
|
||||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
if (!fieldRect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!fieldRect) {
|
const rectWidth = fieldRect.width() * groupScaleX;
|
||||||
return;
|
const rectHeight = fieldRect.height() * groupScaleY;
|
||||||
}
|
|
||||||
|
|
||||||
const rectWidth = fieldRect.width() * groupScaleX;
|
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
const rectHeight = fieldRect.height() * groupScaleY;
|
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
|
||||||
|
|
||||||
const circles = fieldGroup.find('.radio-circle').sort((a, b) => a.id().localeCompare(b.id()));
|
const groupedItems = circles.map((circle, i) => ({
|
||||||
const checkmarks = fieldGroup.find('.radio-dot').sort((a, b) => a.id().localeCompare(b.id()));
|
circleElement: circle,
|
||||||
const text = fieldGroup.find('.radio-text').sort((a, b) => a.id().localeCompare(b.id()));
|
checkmarkElement: checkmarks[i],
|
||||||
|
textElement: text[i],
|
||||||
|
}));
|
||||||
|
|
||||||
const groupedItems = circles.map((circle, i) => ({
|
groupedItems.forEach((item, i) => {
|
||||||
circleElement: circle,
|
const { circleElement, checkmarkElement, textElement } = item;
|
||||||
checkmarkElement: checkmarks[i],
|
|
||||||
textElement: text[i],
|
|
||||||
}));
|
|
||||||
|
|
||||||
groupedItems.forEach((item, i) => {
|
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||||
const { circleElement, checkmarkElement, textElement } = item;
|
calculateMultiItemPosition({
|
||||||
|
fieldWidth: rectWidth,
|
||||||
|
fieldHeight: rectHeight,
|
||||||
|
itemCount: radioValues.length,
|
||||||
|
itemIndex: i,
|
||||||
|
itemSize: radioSize,
|
||||||
|
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||||
|
fieldPadding: radioFieldPadding,
|
||||||
|
type: 'radio',
|
||||||
|
});
|
||||||
|
|
||||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
circleElement.setAttrs({
|
||||||
calculateMultiItemPosition({
|
x: itemInputX,
|
||||||
fieldWidth: rectWidth,
|
y: itemInputY,
|
||||||
fieldHeight: rectHeight,
|
scaleX: 1,
|
||||||
itemCount: radioValues.length,
|
scaleY: 1,
|
||||||
itemIndex: i,
|
|
||||||
itemSize: radioSize,
|
|
||||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
|
||||||
fieldPadding: radioFieldPadding,
|
|
||||||
type: 'radio',
|
|
||||||
direction: radioMeta?.direction || 'vertical',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
circleElement.setAttrs({
|
checkmarkElement.setAttrs({
|
||||||
x: itemInputX,
|
x: itemInputX,
|
||||||
y: itemInputY,
|
y: itemInputY,
|
||||||
scaleX: 1,
|
scaleX: 1,
|
||||||
scaleY: 1,
|
scaleY: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
textElement.setAttrs({
|
||||||
|
x: textX,
|
||||||
|
y: textY,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
width: textWidth,
|
||||||
|
height: textHeight,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
checkmarkElement.setAttrs({
|
fieldRect.width(rectWidth);
|
||||||
x: itemInputX,
|
fieldRect.height(rectHeight);
|
||||||
y: itemInputY,
|
|
||||||
scaleX: 1,
|
fieldGroup.scale({
|
||||||
scaleY: 1,
|
x: 1,
|
||||||
|
y: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
textElement.setAttrs({
|
pageLayer.batchDraw();
|
||||||
x: textX,
|
|
||||||
y: textY,
|
|
||||||
scaleX: 1,
|
|
||||||
scaleY: 1,
|
|
||||||
width: textWidth,
|
|
||||||
height: textHeight,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fieldRect.width(rectWidth);
|
const radioMeta: TRadioFieldMeta | null = (field.fieldMeta as TRadioFieldMeta) || null;
|
||||||
fieldRect.height(rectHeight);
|
const radioValues = radioMeta?.values || [];
|
||||||
|
|
||||||
fieldGroup.scale({
|
|
||||||
x: 1,
|
|
||||||
y: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
pageLayer.batchDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
|
||||||
|
|
||||||
radioValues.forEach(({ value, checked }, index) => {
|
radioValues.forEach(({ value, checked }, index) => {
|
||||||
const isRadioValueChecked = match(mode)
|
|
||||||
.with('edit', () => checked)
|
|
||||||
.with('sign', () => value === field.customText)
|
|
||||||
.with('export', () => value === field.customText)
|
|
||||||
.exhaustive();
|
|
||||||
|
|
||||||
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
const { itemInputX, itemInputY, textX, textY, textWidth, textHeight } =
|
||||||
calculateMultiItemPosition({
|
calculateMultiItemPosition({
|
||||||
fieldWidth,
|
fieldWidth,
|
||||||
@ -130,7 +120,6 @@ export const renderRadioFieldElement = (
|
|||||||
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
spacingBetweenItemAndText: spacingBetweenRadioAndText,
|
||||||
fieldPadding: radioFieldPadding,
|
fieldPadding: radioFieldPadding,
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
direction: radioMeta?.direction || 'vertical',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Circle which represents the radio button.
|
// Circle which represents the radio button.
|
||||||
@ -155,7 +144,9 @@ export const renderRadioFieldElement = (
|
|||||||
y: itemInputY,
|
y: itemInputY,
|
||||||
radius: radioSize / 4,
|
radius: radioSize / 4,
|
||||||
fill: '#111827',
|
fill: '#111827',
|
||||||
visible: isRadioValueChecked,
|
// Todo: Envelopes
|
||||||
|
visible: value === field.customText,
|
||||||
|
// visible: checked,
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = new Konva.Text({
|
const text = new Konva.Text({
|
||||||
|
|||||||
@ -96,80 +96,77 @@ export const renderSignatureFieldElement = (
|
|||||||
|
|
||||||
const fieldGroup = upsertFieldGroup(field, options);
|
const fieldGroup = upsertFieldGroup(field, options);
|
||||||
|
|
||||||
// Clear previous children and listeners to re-render fresh.
|
// ABOVE IS GENERIC, EXTRACT IT.
|
||||||
fieldGroup.removeChildren();
|
|
||||||
fieldGroup.off('transform');
|
|
||||||
|
|
||||||
// Assign elements to group and any listeners that should only be run on initialization.
|
|
||||||
if (isFirstRender) {
|
|
||||||
pageLayer.add(fieldGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the field background and text.
|
// Render the field background and text.
|
||||||
const fieldRect = upsertFieldRect(field, options);
|
const fieldRect = upsertFieldRect(field, options);
|
||||||
const fieldText = upsertFieldText(field, options);
|
const fieldText = upsertFieldText(field, options);
|
||||||
|
|
||||||
fieldGroup.add(fieldRect);
|
// Assign elements to group and any listeners that should only be run on initialization.
|
||||||
fieldGroup.add(fieldText);
|
if (isFirstRender) {
|
||||||
|
fieldGroup.add(fieldRect);
|
||||||
|
fieldGroup.add(fieldText);
|
||||||
|
pageLayer.add(fieldGroup);
|
||||||
|
|
||||||
// This is to keep the text inside the field at the same size
|
// This is to keep the text inside the field at the same size
|
||||||
// when the field is resized. Without this the text would be stretched.
|
// when the field is resized. Without this the text would be stretched.
|
||||||
fieldGroup.on('transform', () => {
|
fieldGroup.on('transform', () => {
|
||||||
const groupScaleX = fieldGroup.scaleX();
|
const groupScaleX = fieldGroup.scaleX();
|
||||||
const groupScaleY = fieldGroup.scaleY();
|
const groupScaleY = fieldGroup.scaleY();
|
||||||
|
|
||||||
// Adjust text scale so it doesn't change while group is resized.
|
// Adjust text scale so it doesn't change while group is resized.
|
||||||
fieldText.scaleX(1 / groupScaleX);
|
fieldText.scaleX(1 / groupScaleX);
|
||||||
fieldText.scaleY(1 / groupScaleY);
|
fieldText.scaleY(1 / groupScaleY);
|
||||||
|
|
||||||
const rectWidth = fieldRect.width() * groupScaleX;
|
const rectWidth = fieldRect.width() * groupScaleX;
|
||||||
const rectHeight = fieldRect.height() * groupScaleY;
|
const rectHeight = fieldRect.height() * groupScaleY;
|
||||||
|
|
||||||
// // Update text group position and clipping
|
// // Update text group position and clipping
|
||||||
// fieldGroup.clipFunc(function (ctx) {
|
// fieldGroup.clipFunc(function (ctx) {
|
||||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// Update text dimensions
|
// Update text dimensions
|
||||||
fieldText.width(rectWidth); // Account for padding
|
fieldText.width(rectWidth); // Account for padding
|
||||||
fieldText.height(rectHeight);
|
fieldText.height(rectHeight);
|
||||||
|
|
||||||
console.log({
|
console.log({
|
||||||
rectWidth,
|
rectWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force Konva to recalculate text layout
|
||||||
|
// textInsideField.getTextHeight(); // This forces recalculation
|
||||||
|
fieldText.height(); // This forces recalculation
|
||||||
|
|
||||||
|
// fieldGroup.draw();
|
||||||
|
fieldGroup.getLayer()?.batchDraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force Konva to recalculate text layout
|
// Reset the text after transform has ended.
|
||||||
// textInsideField.getTextHeight(); // This forces recalculation
|
fieldGroup.on('transformend', () => {
|
||||||
fieldText.height(); // This forces recalculation
|
fieldText.scaleX(1);
|
||||||
|
fieldText.scaleY(1);
|
||||||
|
|
||||||
// fieldGroup.draw();
|
const rectWidth = fieldRect.width();
|
||||||
fieldGroup.getLayer()?.batchDraw();
|
const rectHeight = fieldRect.height();
|
||||||
});
|
|
||||||
|
|
||||||
// Reset the text after transform has ended.
|
// // Update text group position and clipping
|
||||||
fieldGroup.on('transformend', () => {
|
// fieldGroup.clipFunc(function (ctx) {
|
||||||
fieldText.scaleX(1);
|
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||||
fieldText.scaleY(1);
|
// });
|
||||||
|
|
||||||
const rectWidth = fieldRect.width();
|
// Update text dimensions
|
||||||
const rectHeight = fieldRect.height();
|
fieldText.width(rectWidth); // Account for padding
|
||||||
|
fieldText.height(rectHeight);
|
||||||
|
|
||||||
// // Update text group position and clipping
|
// Force Konva to recalculate text layout
|
||||||
// fieldGroup.clipFunc(function (ctx) {
|
// textInsideField.getTextHeight(); // This forces recalculation
|
||||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
fieldText.height(); // This forces recalculation
|
||||||
// });
|
|
||||||
|
|
||||||
// Update text dimensions
|
// fieldGroup.draw();
|
||||||
fieldText.width(rectWidth); // Account for padding
|
fieldGroup.getLayer()?.batchDraw();
|
||||||
fieldText.height(rectHeight);
|
});
|
||||||
|
}
|
||||||
// Force Konva to recalculate text layout
|
|
||||||
// textInsideField.getTextHeight(); // This forces recalculation
|
|
||||||
fieldText.height(); // This forces recalculation
|
|
||||||
|
|
||||||
// fieldGroup.draw();
|
|
||||||
fieldGroup.getLayer()?.batchDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle export mode.
|
// Handle export mode.
|
||||||
if (mode === 'export') {
|
if (mode === 'export') {
|
||||||
|
|||||||
@ -121,80 +121,77 @@ export const renderTextFieldElement = (
|
|||||||
|
|
||||||
const fieldGroup = upsertFieldGroup(field, options);
|
const fieldGroup = upsertFieldGroup(field, options);
|
||||||
|
|
||||||
// Clear previous children and listeners to re-render fresh.
|
// ABOVE IS GENERIC, EXTRACT IT.
|
||||||
fieldGroup.removeChildren();
|
|
||||||
fieldGroup.off('transform');
|
|
||||||
|
|
||||||
// Assign elements to group and any listeners that should only be run on initialization.
|
|
||||||
if (isFirstRender) {
|
|
||||||
pageLayer.add(fieldGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the field background and text.
|
// Render the field background and text.
|
||||||
const fieldRect = upsertFieldRect(field, options);
|
const fieldRect = upsertFieldRect(field, options);
|
||||||
const fieldText = upsertFieldText(field, options);
|
const fieldText = upsertFieldText(field, options);
|
||||||
|
|
||||||
fieldGroup.add(fieldRect);
|
// Assign elements to group and any listeners that should only be run on initialization.
|
||||||
fieldGroup.add(fieldText);
|
if (isFirstRender) {
|
||||||
|
fieldGroup.add(fieldRect);
|
||||||
|
fieldGroup.add(fieldText);
|
||||||
|
pageLayer.add(fieldGroup);
|
||||||
|
|
||||||
// This is to keep the text inside the field at the same size
|
// This is to keep the text inside the field at the same size
|
||||||
// when the field is resized. Without this the text would be stretched.
|
// when the field is resized. Without this the text would be stretched.
|
||||||
fieldGroup.on('transform', () => {
|
fieldGroup.on('transform', () => {
|
||||||
const groupScaleX = fieldGroup.scaleX();
|
const groupScaleX = fieldGroup.scaleX();
|
||||||
const groupScaleY = fieldGroup.scaleY();
|
const groupScaleY = fieldGroup.scaleY();
|
||||||
|
|
||||||
// Adjust text scale so it doesn't change while group is resized.
|
// Adjust text scale so it doesn't change while group is resized.
|
||||||
fieldText.scaleX(1 / groupScaleX);
|
fieldText.scaleX(1 / groupScaleX);
|
||||||
fieldText.scaleY(1 / groupScaleY);
|
fieldText.scaleY(1 / groupScaleY);
|
||||||
|
|
||||||
const rectWidth = fieldRect.width() * groupScaleX;
|
const rectWidth = fieldRect.width() * groupScaleX;
|
||||||
const rectHeight = fieldRect.height() * groupScaleY;
|
const rectHeight = fieldRect.height() * groupScaleY;
|
||||||
|
|
||||||
// // Update text group position and clipping
|
// // Update text group position and clipping
|
||||||
// fieldGroup.clipFunc(function (ctx) {
|
// fieldGroup.clipFunc(function (ctx) {
|
||||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// Update text dimensions
|
// Update text dimensions
|
||||||
fieldText.width(rectWidth); // Account for padding
|
fieldText.width(rectWidth); // Account for padding
|
||||||
fieldText.height(rectHeight);
|
fieldText.height(rectHeight);
|
||||||
|
|
||||||
console.log({
|
console.log({
|
||||||
rectWidth,
|
rectWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force Konva to recalculate text layout
|
||||||
|
// textInsideField.getTextHeight(); // This forces recalculation
|
||||||
|
fieldText.height(); // This forces recalculation
|
||||||
|
|
||||||
|
// fieldGroup.draw();
|
||||||
|
fieldGroup.getLayer()?.batchDraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Force Konva to recalculate text layout
|
// Reset the text after transform has ended.
|
||||||
// textInsideField.getTextHeight(); // This forces recalculation
|
fieldGroup.on('transformend', () => {
|
||||||
fieldText.height(); // This forces recalculation
|
fieldText.scaleX(1);
|
||||||
|
fieldText.scaleY(1);
|
||||||
|
|
||||||
// fieldGroup.draw();
|
const rectWidth = fieldRect.width();
|
||||||
fieldGroup.getLayer()?.batchDraw();
|
const rectHeight = fieldRect.height();
|
||||||
});
|
|
||||||
|
|
||||||
// Reset the text after transform has ended.
|
// // Update text group position and clipping
|
||||||
fieldGroup.on('transformend', () => {
|
// fieldGroup.clipFunc(function (ctx) {
|
||||||
fieldText.scaleX(1);
|
// ctx.rect(0, 0, rectWidth, rectHeight);
|
||||||
fieldText.scaleY(1);
|
// });
|
||||||
|
|
||||||
const rectWidth = fieldRect.width();
|
// Update text dimensions
|
||||||
const rectHeight = fieldRect.height();
|
fieldText.width(rectWidth); // Account for padding
|
||||||
|
fieldText.height(rectHeight);
|
||||||
|
|
||||||
// // Update text group position and clipping
|
// Force Konva to recalculate text layout
|
||||||
// fieldGroup.clipFunc(function (ctx) {
|
// textInsideField.getTextHeight(); // This forces recalculation
|
||||||
// ctx.rect(0, 0, rectWidth, rectHeight);
|
fieldText.height(); // This forces recalculation
|
||||||
// });
|
|
||||||
|
|
||||||
// Update text dimensions
|
// fieldGroup.draw();
|
||||||
fieldText.width(rectWidth); // Account for padding
|
fieldGroup.getLayer()?.batchDraw();
|
||||||
fieldText.height(rectHeight);
|
});
|
||||||
|
}
|
||||||
// Force Konva to recalculate text layout
|
|
||||||
// textInsideField.getTextHeight(); // This forces recalculation
|
|
||||||
fieldText.height(); // This forces recalculation
|
|
||||||
|
|
||||||
// fieldGroup.draw();
|
|
||||||
fieldGroup.getLayer()?.batchDraw();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle export mode.
|
// Handle export mode.
|
||||||
if (mode === 'export') {
|
if (mode === 'export') {
|
||||||
|
|||||||
37
packages/lib/utils/is-valid-return-to.ts
Normal file
37
packages/lib/utils/is-valid-return-to.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
|
||||||
|
export const isValidReturnTo = (returnTo?: string) => {
|
||||||
|
if (!returnTo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode if it's URL encoded
|
||||||
|
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||||
|
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
|
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeReturnTo = (returnTo?: string) => {
|
||||||
|
if (!returnTo) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode if it's URL encoded
|
||||||
|
const decodedReturnTo = decodeURIComponent(returnTo);
|
||||||
|
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
|
||||||
|
|
||||||
|
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EnvelopeAttachment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"data" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"envelopeId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "EnvelopeAttachment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "EnvelopeAttachment" ADD CONSTRAINT "EnvelopeAttachment_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -422,6 +422,8 @@ model Envelope {
|
|||||||
|
|
||||||
documentMetaId String @unique
|
documentMetaId String @unique
|
||||||
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
|
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
|
||||||
|
|
||||||
|
envelopeAttachments EnvelopeAttachment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model EnvelopeItem {
|
model EnvelopeItem {
|
||||||
@ -508,6 +510,22 @@ model DocumentMeta {
|
|||||||
envelope Envelope?
|
envelope Envelope?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// @zod.import(["import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';"])
|
||||||
|
model EnvelopeAttachment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
type String /// [EnvelopeAttachmentType] @zod.custom.use(ZEnvelopeAttachmentTypeSchema)
|
||||||
|
label String
|
||||||
|
|
||||||
|
data String
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
envelopeId String
|
||||||
|
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
enum ReadStatus {
|
enum ReadStatus {
|
||||||
NOT_OPENED
|
NOT_OPENED
|
||||||
OPENED
|
OPENED
|
||||||
|
|||||||
3
packages/prisma/types/types.d.ts
vendored
3
packages/prisma/types/types.d.ts
vendored
@ -5,6 +5,7 @@ import type {
|
|||||||
} from '@documenso/lib/types/document-auth';
|
} from '@documenso/lib/types/document-auth';
|
||||||
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
|
||||||
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
|
import type { TDocumentFormValues } from '@documenso/lib/types/document-form-values';
|
||||||
|
import type { TEnvelopeAttachmentType } from '@documenso/lib/types/envelope-attachment';
|
||||||
import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta';
|
import type { TFieldMetaNotOptionalSchema } from '@documenso/lib/types/field-meta';
|
||||||
import type { TClaimFlags } from '@documenso/lib/types/subscription';
|
import type { TClaimFlags } from '@documenso/lib/types/subscription';
|
||||||
|
|
||||||
@ -23,6 +24,8 @@ declare global {
|
|||||||
type RecipientAuthOptions = TRecipientAuthOptions;
|
type RecipientAuthOptions = TRecipientAuthOptions;
|
||||||
|
|
||||||
type FieldMeta = TFieldMetaNotOptionalSchema;
|
type FieldMeta = TFieldMetaNotOptionalSchema;
|
||||||
|
|
||||||
|
type EnvelopeAttachmentType = TEnvelopeAttachmentType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user