mirror of
https://github.com/documenso/documenso.git
synced 2026-06-24 21:32:09 +10:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0587731794 | |||
| 4996c955cc | |||
| 90e5926e2f | |||
| 187b612568 | |||
| b37529a1cf | |||
| 04f6e76178 | |||
| 8403d6cdca | |||
| f2525ae95b | |||
| 2f24a8eab2 | |||
| d9b7722325 | |||
| 783123f72b | |||
| e8ed1c3d99 | |||
| c23d739f76 | |||
| 0bf58ca66e | |||
| dee3259088 | |||
| 6ad1a2dfaf | |||
| 306e7fe5ed | |||
| 219db32fdf | |||
| 948d1bbf12 |
@@ -1,243 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentMoveToFolderDialogProps = {
|
||||
documentId: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentFolderId?: string;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZMoveDocumentFormSchema = z.object({
|
||||
folderId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
type TMoveDocumentFormSchema = z.infer<typeof ZMoveDocumentFormSchema>;
|
||||
|
||||
export const DocumentMoveToFolderDialog = ({
|
||||
documentId,
|
||||
open,
|
||||
onOpenChange,
|
||||
currentFolderId,
|
||||
...props
|
||||
}: DocumentMoveToFolderDialogProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const form = useForm<TMoveDocumentFormSchema>({
|
||||
resolver: zodResolver(ZMoveDocumentFormSchema),
|
||||
defaultValues: {
|
||||
folderId: currentFolderId,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId,
|
||||
type: FolderType.DOCUMENT,
|
||||
},
|
||||
{
|
||||
enabled: open,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: updateDocument } = trpc.document.update.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId });
|
||||
}
|
||||
}, [open, currentFolderId, form]);
|
||||
|
||||
const onSubmit = async (data: TMoveDocumentFormSchema) => {
|
||||
try {
|
||||
await updateDocument({
|
||||
documentId,
|
||||
data: {
|
||||
folderId: data.folderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
if (data.folderId) {
|
||||
await navigate(`${documentsPath}/f/${data.folderId}`);
|
||||
} else {
|
||||
await navigate(documentsPath);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Document moved`),
|
||||
description: _(msg`The document has been moved successfully.`),
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`The folder you are trying to move the document to does not exist.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`You are not allowed to move this document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while moving the document.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Move Document to Folder</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Select a folder to move this document to.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={_(msg`Search folders...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Folder</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{isFoldersLoading ? (
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === null ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(null)}
|
||||
disabled={currentFolderId === null}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home (No Folder)</Trans>
|
||||
</Button>
|
||||
|
||||
{filteredFolders?.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
variant={field.value === folder.id ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(folder.id)}
|
||||
disabled={currentFolderId === folder.id}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{folder.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{searchTerm && filteredFolders?.length === 0 && (
|
||||
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
|
||||
<Trans>No folders found</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFoldersLoading || form.formState.isSubmitting || currentFolderId === null}
|
||||
>
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import type { TRecipientLite } from '@documenso/lib/types/recipient';
|
||||
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
|
||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SigningStatus, type Team, type User } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||
|
||||
import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
const FORM_ID = 'resend-email';
|
||||
|
||||
export type DocumentResendDialogProps = {
|
||||
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
|
||||
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||
recipients: TRecipientLite[];
|
||||
team: Pick<Team, 'id' | 'url'> | null;
|
||||
};
|
||||
recipients: TRecipientLite[];
|
||||
};
|
||||
|
||||
export const ZResendDocumentFormSchema = z.object({
|
||||
recipients: z.array(z.number()).min(1, {
|
||||
message: 'You must select at least one item.',
|
||||
}),
|
||||
});
|
||||
|
||||
export type TResendDocumentFormSchema = z.infer<typeof ZResendDocumentFormSchema>;
|
||||
|
||||
export const DocumentResendDialog = ({ document, recipients }: DocumentResendDialogProps) => {
|
||||
const { user } = useSession();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const isOwner = document.userId === user.id;
|
||||
const isCurrentTeamDocument = team && document.team?.url === team.url;
|
||||
|
||||
const isDisabled =
|
||||
(!isOwner && !isCurrentTeamDocument) ||
|
||||
document.status !== 'PENDING' ||
|
||||
!recipients.some((r) => r.signingStatus === SigningStatus.NOT_SIGNED);
|
||||
|
||||
const { mutateAsync: resendDocument } = trpcReact.document.redistribute.useMutation();
|
||||
|
||||
const form = useForm<TResendDocumentFormSchema>({
|
||||
resolver: zodResolver(ZResendDocumentFormSchema),
|
||||
defaultValues: {
|
||||
recipients: [],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const selectedRecipients = useWatch({
|
||||
control: form.control,
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
|
||||
toast({
|
||||
title: _(msg`Document re-sent`),
|
||||
description: _(msg`Your document has been re-sent successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
const errorMessage = getDistributeErrorMessage(error.code);
|
||||
|
||||
toast({
|
||||
title: _(errorMessage.title),
|
||||
description: _(errorMessage.description),
|
||||
variant: 'destructive',
|
||||
duration: 7500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem disabled={isDisabled} onSelect={(e) => e.preventDefault()}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
<Trans>Resend</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-sm" hideClose>
|
||||
<DialogHeader>
|
||||
<DialogTitle asChild>
|
||||
<h1 className="text-center text-xl">
|
||||
<Trans>Who do you want to remind?</Trans>
|
||||
</h1>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form id={FORM_ID} onSubmit={handleSubmit(onFormSubmit)} className="px-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipients"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<>
|
||||
{recipients.map((recipient) => (
|
||||
<FormItem key={recipient.id} className="flex flex-row items-center justify-between gap-x-3">
|
||||
<FormLabel
|
||||
className={cn('my-2 flex items-center gap-2 font-normal', {
|
||||
'opacity-50': !value.includes(recipient.id),
|
||||
})}
|
||||
>
|
||||
<StackAvatar
|
||||
key={recipient.id}
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
{recipient.email}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full border border-neutral-400"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
checked
|
||||
? onChange([...value, recipient.id])
|
||||
: onChange(value.filter((v) => v !== recipient.id))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
className="flex-1"
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={isSubmitting || selectedRecipients.length === 0}
|
||||
>
|
||||
<Trans>Send reminder</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,12 +3,13 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { hasOverlappingFields } from '@documenso/lib/utils/fields-overlap';
|
||||
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -32,7 +33,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { AlertTriangleIcon, InfoIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -138,6 +139,27 @@ export const EnvelopeDistributeDialog = ({
|
||||
});
|
||||
}, [recipientsWithIndex, envelope.authOptions]);
|
||||
|
||||
/**
|
||||
* Whether any fields significantly overlap each other. This is surfaced as a
|
||||
* non-blocking warning since overlapping fields still allow sending, but can
|
||||
* complicate the signing process or cause fields to behave unexpectedly.
|
||||
*/
|
||||
const hasOverlappingEnvelopeFields = useMemo(
|
||||
() =>
|
||||
hasOverlappingFields(
|
||||
envelope.fields.map((field) => ({
|
||||
id: field.id,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
width: Number(field.width),
|
||||
height: Number(field.height),
|
||||
})),
|
||||
),
|
||||
[envelope.fields],
|
||||
);
|
||||
|
||||
const invalidEnvelopeCode = useMemo(() => {
|
||||
if (recipientsMissingSignatureFields.length > 0) {
|
||||
return 'MISSING_SIGNATURES';
|
||||
@@ -206,6 +228,11 @@ export const EnvelopeDistributeDialog = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Default the distribution method tab to the envelope's configured setting.
|
||||
if (isOpen && envelope.documentMeta) {
|
||||
setValue('meta.distributionMethod', envelope.documentMeta.distributionMethod);
|
||||
}
|
||||
|
||||
// Resync the whole envelope if the envelope is mid saving.
|
||||
if (isOpen && (isAutosaving || autosaveError)) {
|
||||
void handleSync();
|
||||
@@ -235,6 +262,24 @@ export const EnvelopeDistributeDialog = ({
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isSubmitting}>
|
||||
{hasOverlappingEnvelopeFields && (
|
||||
<Alert variant="warning" className="mb-4 flex flex-row items-start gap-3">
|
||||
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<AlertTitle>
|
||||
<Trans>Overlapping fields detected</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Some fields are placed on top of each other. This may complicate the signing process or cause
|
||||
fields to not work as expected.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
onValueChange={(value) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Checkbox } from '@documenso/ui/primitives/checkbox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -11,10 +12,12 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EnvelopeType } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -37,6 +40,15 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
||||
|
||||
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
includeRecipients: true,
|
||||
includeFields: true,
|
||||
},
|
||||
});
|
||||
|
||||
const includeRecipients = form.watch('includeRecipients');
|
||||
|
||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({
|
||||
onSuccess: async ({ id }) => {
|
||||
toast({
|
||||
@@ -55,8 +67,14 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
||||
});
|
||||
|
||||
const onDuplicate = async () => {
|
||||
const { includeRecipients, includeFields } = form.getValues();
|
||||
|
||||
try {
|
||||
await duplicateEnvelope({ envelopeId });
|
||||
await duplicateEnvelope({
|
||||
envelopeId,
|
||||
includeRecipients,
|
||||
includeFields: includeRecipients && includeFields,
|
||||
});
|
||||
} catch {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
@@ -70,7 +88,20 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDuplicating && setOpen(value)}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (isDuplicating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(value);
|
||||
|
||||
if (!value) {
|
||||
form.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
|
||||
<DialogContent>
|
||||
@@ -87,6 +118,49 @@ export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: E
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="includeRecipients"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="envelopeDuplicateIncludeRecipients"
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked === true);
|
||||
|
||||
if (!checked) {
|
||||
form.setValue('includeFields', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="envelopeDuplicateIncludeRecipients">
|
||||
<Trans>Include Recipients</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="includeFields"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="envelopeDuplicateIncludeFields"
|
||||
checked={field.value}
|
||||
disabled={!includeRecipients}
|
||||
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="envelopeDuplicateIncludeFields" className={!includeRecipients ? 'opacity-50' : ''}>
|
||||
<Trans>Include Fields</Trans>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isDuplicating}>
|
||||
|
||||
@@ -25,14 +25,16 @@ import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
import * as z from 'zod';
|
||||
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
|
||||
import { StackAvatar } from '../general/stack-avatar';
|
||||
|
||||
export type EnvelopeRedistributeDialogProps = {
|
||||
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
|
||||
envelope: Pick<TEnvelope, 'id' | 'status' | 'type'> & {
|
||||
recipients: TEnvelopeRecipientLite[];
|
||||
};
|
||||
envelopeType?: EnvelopeType;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -44,7 +46,7 @@ export const ZEnvelopeRedistributeFormSchema = z.object({
|
||||
|
||||
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
|
||||
|
||||
export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => {
|
||||
export const EnvelopeRedistributeDialog = ({ envelope, envelopeType, trigger }: EnvelopeRedistributeDialogProps) => {
|
||||
const recipients = envelope.recipients;
|
||||
|
||||
const { toast } = useToast();
|
||||
@@ -70,9 +72,23 @@ export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedist
|
||||
try {
|
||||
await redistributeEnvelope({ envelopeId: envelope.id, recipients });
|
||||
|
||||
const successMessage = match(envelopeType)
|
||||
.with(EnvelopeType.DOCUMENT, () => ({
|
||||
title: t`Document resent`,
|
||||
description: t`Your document has been resent successfully.`,
|
||||
}))
|
||||
.with(EnvelopeType.TEMPLATE, () => ({
|
||||
title: t`Template resent`,
|
||||
description: t`Your template has been resent successfully.`,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
title: t`Envelope resent`,
|
||||
description: t`Your envelope has been resent successfully.`,
|
||||
}));
|
||||
|
||||
toast({
|
||||
title: t`Envelope resent`,
|
||||
description: t`Your envelope has been resent successfully.`,
|
||||
title: successMessage.title,
|
||||
description: successMessage.description,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export type EnvelopesBulkMoveDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentFolderId?: string;
|
||||
onSuccess?: () => void;
|
||||
onSuccess?: (folderId: string | null) => Promise<void> | void;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZBulkMoveFormSchema = z.object({
|
||||
@@ -99,11 +99,12 @@ export const EnvelopesBulkMoveDialog = ({
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
}
|
||||
|
||||
await onSuccess?.(data.folderId);
|
||||
|
||||
toast({
|
||||
description: t`Selected items have been moved.`,
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
@@ -16,6 +16,17 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
/**
|
||||
* The reason a team member cannot be removed from the team. When set, the delete
|
||||
* dialog explains the reason instead of offering a confirm button.
|
||||
*/
|
||||
export type TeamMemberDeleteDisableReason =
|
||||
| 'TEAM_OWNER'
|
||||
| 'HIGHER_ROLE'
|
||||
| 'INHERIT_MEMBER_ENABLED'
|
||||
| 'INHERITED_MEMBER';
|
||||
|
||||
export type TeamMemberDeleteDialogProps = {
|
||||
teamId: number;
|
||||
@@ -23,7 +34,7 @@ export type TeamMemberDeleteDialogProps = {
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
memberEmail: string;
|
||||
isInheritMemberEnabled: boolean | null;
|
||||
disableReason?: TeamMemberDeleteDisableReason | null;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -34,7 +45,7 @@ export const TeamMemberDeleteDialog = ({
|
||||
memberId,
|
||||
memberName,
|
||||
memberEmail,
|
||||
isInheritMemberEnabled,
|
||||
disableReason,
|
||||
}: TeamMemberDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -86,10 +97,19 @@ export const TeamMemberDeleteDialog = ({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isInheritMemberEnabled ? (
|
||||
{disableReason ? (
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>You cannot remove members from this team if the inherit member feature is enabled.</Trans>
|
||||
{match(disableReason)
|
||||
.with('TEAM_OWNER', () => <Trans>You cannot remove the organisation owner from the team.</Trans>)
|
||||
.with('HIGHER_ROLE', () => <Trans>You cannot remove a member with a role higher than your own.</Trans>)
|
||||
.with('INHERIT_MEMBER_ENABLED', () => (
|
||||
<Trans>You cannot remove members from this team while the inherit member feature is enabled.</Trans>
|
||||
))
|
||||
.with('INHERITED_MEMBER', () => (
|
||||
<Trans>This member is inherited from a group and cannot be removed from the team directly.</Trans>
|
||||
))
|
||||
.exhaustive()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
@@ -109,11 +129,10 @@ export const TeamMemberDeleteDialog = ({
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
{!isInheritMemberEnabled && (
|
||||
{!disableReason && (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={Boolean(isInheritMemberEnabled)}
|
||||
loading={isDeletingTeamMember}
|
||||
onClick={async () => deleteTeamMember({ teamId, memberId })}
|
||||
>
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { FolderType } from '@documenso/lib/types/folder-type';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TemplateMoveToFolderDialogProps = {
|
||||
templateId: number;
|
||||
templateTitle: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentFolderId?: string | null;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZMoveTemplateFormSchema = z.object({
|
||||
folderId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
type TMoveTemplateFormSchema = z.infer<typeof ZMoveTemplateFormSchema>;
|
||||
|
||||
export function TemplateMoveToFolderDialog({
|
||||
templateId,
|
||||
templateTitle,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
currentFolderId,
|
||||
...props
|
||||
}: TemplateMoveToFolderDialogProps) {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const form = useForm<TMoveTemplateFormSchema>({
|
||||
resolver: zodResolver(ZMoveTemplateFormSchema),
|
||||
defaultValues: {
|
||||
folderId: currentFolderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
|
||||
{
|
||||
parentId: currentFolderId ?? null,
|
||||
type: FolderType.TEMPLATE,
|
||||
},
|
||||
{
|
||||
enabled: isOpen,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: updateTemplate } = trpc.template.updateTemplate.useMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
form.reset();
|
||||
setSearchTerm('');
|
||||
} else {
|
||||
form.reset({ folderId: currentFolderId ?? null });
|
||||
}
|
||||
}, [isOpen, currentFolderId, form]);
|
||||
|
||||
const onSubmit = async (data: TMoveTemplateFormSchema) => {
|
||||
try {
|
||||
await updateTemplate({
|
||||
templateId,
|
||||
data: {
|
||||
folderId: data.folderId ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Template moved`),
|
||||
description: _(msg`The template has been moved successfully.`),
|
||||
variant: 'default',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
if (data.folderId) {
|
||||
void navigate(`${templatesPath}/f/${data.folderId}`);
|
||||
} else {
|
||||
void navigate(templatesPath);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.code === AppErrorCode.NOT_FOUND) {
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`The folder you are trying to move the template to does not exist.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: _(msg`Error`),
|
||||
description: _(msg`An error occurred while moving the template.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFolders = folders?.data?.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Move Template to Folder</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>Move "{templateTitle}" to a folder</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={_(msg`Search folders...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4 flex flex-col gap-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Folder</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{isFoldersLoading ? (
|
||||
<div className="flex h-10 items-center justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.value === null ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(null)}
|
||||
disabled={currentFolderId === null}
|
||||
>
|
||||
<HomeIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Home (No Folder)</Trans>
|
||||
</Button>
|
||||
|
||||
{filteredFolders?.map((folder) => (
|
||||
<Button
|
||||
key={folder.id}
|
||||
type="button"
|
||||
variant={field.value === folder.id ? 'default' : 'outline'}
|
||||
className="w-full justify-start"
|
||||
onClick={() => field.onChange(folder.id)}
|
||||
disabled={currentFolderId === folder.id}
|
||||
>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
{folder.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{searchTerm && filteredFolders?.length === 0 && (
|
||||
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
|
||||
<Trans>No folders found</Trans>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={isFoldersLoading || form.formState.isSubmitting}>
|
||||
<Trans>Move</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationGlobalSettings, TeamGlobalSettings } from '@prisma/client';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
|
||||
@@ -25,38 +26,72 @@ const emailSettingsKeys = Object.keys(EMAIL_SETTINGS_LABELS) as (keyof TDocument
|
||||
type AdminGlobalSettingsSectionProps = {
|
||||
settings: TeamGlobalSettings | OrganisationGlobalSettings | null;
|
||||
isTeam?: boolean;
|
||||
/** When viewing a team, the parent organisation settings the team inherits from. */
|
||||
inheritedSettings?: OrganisationGlobalSettings | null;
|
||||
};
|
||||
|
||||
export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGlobalSettingsSectionProps) => {
|
||||
export const AdminGlobalSettingsSection = ({
|
||||
settings,
|
||||
isTeam = false,
|
||||
inheritedSettings,
|
||||
}: AdminGlobalSettingsSectionProps) => {
|
||||
const { _ } = useLingui();
|
||||
const notSetLabel = isTeam ? <Trans>Inherited</Trans> : <Trans>Not set</Trans>;
|
||||
|
||||
if (!settings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
const notSet = <Trans>Not set</Trans>;
|
||||
|
||||
const inheritedValue = (value: ReactNode) => {
|
||||
if (!isTeam || value === null) {
|
||||
return notSet;
|
||||
}
|
||||
|
||||
return value;
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>Inherited</Trans>:
|
||||
</span>
|
||||
<span>{value}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const brandingTextValue = (value: string | null | undefined) => {
|
||||
if (value === null || value === undefined || value.trim() === '') {
|
||||
return notSetLabel;
|
||||
const textValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||
if (value && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return value;
|
||||
if (inherited && inherited.trim() !== '') {
|
||||
return inheritedValue(inherited);
|
||||
}
|
||||
|
||||
return notSet;
|
||||
};
|
||||
|
||||
const booleanValue = (value: boolean | null | undefined) => {
|
||||
if (value === null || value === undefined) {
|
||||
return notSetLabel;
|
||||
const booleanLabel = (value: boolean) => (value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>);
|
||||
|
||||
const booleanValue = (value: boolean | null | undefined, inherited?: boolean | null) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
return booleanLabel(value);
|
||||
}
|
||||
|
||||
return value ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>;
|
||||
return inherited !== null && inherited !== undefined ? inheritedValue(booleanLabel(inherited)) : notSet;
|
||||
};
|
||||
|
||||
const visibilityLabel = (value: string | null | undefined) => {
|
||||
return value && DOCUMENT_VISIBILITY[value] ? _(DOCUMENT_VISIBILITY[value].value) : null;
|
||||
};
|
||||
|
||||
const visibilityValue = (value: string | null | undefined, inherited?: string | null) => {
|
||||
const label = visibilityLabel(value);
|
||||
|
||||
if (label !== null) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return inheritedValue(visibilityLabel(inherited));
|
||||
};
|
||||
|
||||
const parsedEmailSettings = ZDocumentEmailSettingsSchema.safeParse(settings.emailDocumentSettings);
|
||||
@@ -65,70 +100,82 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<DetailsCard label={<Trans>Document visibility</Trans>}>
|
||||
<DetailsValue>
|
||||
{settings.documentVisibility != null
|
||||
? _(DOCUMENT_VISIBILITY[settings.documentVisibility].value)
|
||||
: notSetLabel}
|
||||
{visibilityValue(settings.documentVisibility, inheritedSettings?.documentVisibility)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document language</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentLanguage)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentLanguage, inheritedSettings?.documentLanguage)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Document timezone</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentTimezone)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentTimezone, inheritedSettings?.documentTimezone)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Date format</Trans>}>
|
||||
<DetailsValue>{textValue(settings.documentDateFormat)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.documentDateFormat, inheritedSettings?.documentDateFormat)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include sender details</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeSenderDetails)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.includeSenderDetails, inheritedSettings?.includeSenderDetails)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include signing certificate</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeSigningCertificate)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.includeSigningCertificate, inheritedSettings?.includeSigningCertificate)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Include audit log</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.includeAuditLog)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.includeAuditLog, inheritedSettings?.includeAuditLog)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Delegate document ownership</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.delegateDocumentOwnership)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.delegateDocumentOwnership, inheritedSettings?.delegateDocumentOwnership)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Typed signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.typedSignatureEnabled)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Upload signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.uploadSignatureEnabled)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.uploadSignatureEnabled, inheritedSettings?.uploadSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Draw signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.drawSignatureEnabled)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{booleanValue(settings.drawSignatureEnabled, inheritedSettings?.drawSignatureEnabled)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding logo</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingLogo)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.brandingLogo, inheritedSettings?.brandingLogo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding URL</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingUrl)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.brandingUrl, inheritedSettings?.brandingUrl)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding company details</Trans>}>
|
||||
<DetailsValue>{brandingTextValue(settings.brandingCompanyDetails)}</DetailsValue>
|
||||
<DetailsValue>
|
||||
{textValue(settings.brandingCompanyDetails, inheritedSettings?.brandingCompanyDetails)}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Email reply-to</Trans>}>
|
||||
<DetailsValue>{textValue(settings.emailReplyTo)}</DetailsValue>
|
||||
<DetailsValue>{textValue(settings.emailReplyTo, inheritedSettings?.emailReplyTo)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
{isTeam && parsedEmailSettings.success && (
|
||||
@@ -145,7 +192,7 @@ export const AdminGlobalSettingsSection = ({ settings, isTeam = false }: AdminGl
|
||||
)}
|
||||
|
||||
<DetailsCard label={<Trans>AI features</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled)}</DetailsValue>
|
||||
<DetailsValue>{booleanValue(settings.aiFeaturesEnabled, inheritedSettings?.aiFeaturesEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import type { ReactNode } from 'react';
|
||||
@@ -13,6 +6,13 @@ import type { Control, FieldValues, Path } from 'react-hook-form';
|
||||
|
||||
import { RateLimitArrayInput } from './rate-limit-array-input';
|
||||
|
||||
/**
|
||||
* The rate-limit editor renders its own per-row inline errors, but a submit
|
||||
* attempt can still surface array-level Zod issues (e.g. a committed duplicate
|
||||
* window). Rendering the field's message here guarantees the form never fails
|
||||
* silently when those errors are not tied to a row the editor is showing.
|
||||
*/
|
||||
|
||||
type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
/** e.g. '' for the claim form, 'claims.' for the org admin form. */
|
||||
@@ -20,6 +20,12 @@ type ClaimLimitFieldsProps<T extends FieldValues> = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type LimitGroup = {
|
||||
title: ReactNode;
|
||||
quotaKey: string;
|
||||
rateLimitKey: string;
|
||||
};
|
||||
|
||||
export const ClaimLimitFields = <T extends FieldValues>({
|
||||
control,
|
||||
prefix = '',
|
||||
@@ -30,13 +36,33 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const name = (key: string) => `${prefix}${key}` as Path<T>;
|
||||
|
||||
const renderQuotaField = (key: string, label: ReactNode, description: ReactNode) => (
|
||||
const limitGroups: LimitGroup[] = [
|
||||
{
|
||||
title: <Trans>Documents</Trans>,
|
||||
quotaKey: 'documentQuota',
|
||||
rateLimitKey: 'documentRateLimits',
|
||||
},
|
||||
{
|
||||
title: <Trans>Emails</Trans>,
|
||||
quotaKey: 'emailQuota',
|
||||
rateLimitKey: 'emailRateLimits',
|
||||
},
|
||||
{
|
||||
title: <Trans>API</Trans>,
|
||||
quotaKey: 'apiQuota',
|
||||
rateLimitKey: 'apiRateLimits',
|
||||
},
|
||||
];
|
||||
|
||||
const renderQuotaField = (group: LimitGroup) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(key)}
|
||||
name={name(group.quotaKey)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormLabel className="text-muted-foreground text-xs">
|
||||
<Trans>Monthly quota</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -47,20 +73,18 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? null : parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{description}</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderRateLimitField = (key: string, label: ReactNode) => (
|
||||
const renderRateLimitField = (group: LimitGroup) => (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name(key)}
|
||||
name={name(group.rateLimitKey)}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<RateLimitArrayInput value={field.value ?? []} onChange={field.onChange} disabled={disabled} />
|
||||
</FormControl>
|
||||
@@ -71,27 +95,30 @@ export const ClaimLimitFields = <T extends FieldValues>({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormLabel>
|
||||
<Trans>Limits</Trans>
|
||||
</FormLabel>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Limits</Trans>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Empty quota means unlimited, 0 blocks the resource. Rate limit windows accept values like 5m, 1h or 24h.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderQuotaField(
|
||||
'documentQuota',
|
||||
<Trans>Monthly document quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('documentRateLimits', <Trans>Document rate limits</Trans>)}
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="grid grid-cols-1 divide-y divide-border md:grid-cols-3 md:divide-x md:divide-y-0">
|
||||
{limitGroups.map((group) => (
|
||||
<div key={group.quotaKey} className="space-y-4 p-4">
|
||||
<h4 className="font-semibold text-sm">{group.title}</h4>
|
||||
|
||||
{renderQuotaField(
|
||||
'emailQuota',
|
||||
<Trans>Monthly email quota</Trans>,
|
||||
<Trans>Empty = Unlimited, 0 = Blocked</Trans>,
|
||||
)}
|
||||
{renderRateLimitField('emailRateLimits', <Trans>Email rate limits</Trans>)}
|
||||
|
||||
{renderQuotaField('apiQuota', <Trans>Monthly API quota</Trans>, <Trans>Empty = Unlimited, 0 = Blocked</Trans>)}
|
||||
{renderRateLimitField('apiRateLimits', <Trans>API rate limits</Trans>)}
|
||||
{renderQuotaField(group)}
|
||||
{renderRateLimitField(group)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Download,
|
||||
Edit,
|
||||
FileOutputIcon,
|
||||
History,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -29,10 +30,10 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||
import { EnvelopeRenameDialog } from '~/components/dialogs/envelope-rename-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
@@ -67,8 +68,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-testid="document-page-view-action-btn">
|
||||
@@ -172,13 +171,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentResendDialog
|
||||
document={{
|
||||
...envelope,
|
||||
id: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||
}}
|
||||
recipients={nonSignedRecipients}
|
||||
/>
|
||||
{canManageDocument && (
|
||||
<EnvelopeRedistributeDialog
|
||||
envelope={envelope}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
<Trans>Resend</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||
|
||||
+91
-1
@@ -1,3 +1,4 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
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';
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from '@documenso/lib/universal/field-renderer/field-renderer';
|
||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import {
|
||||
Command,
|
||||
@@ -62,6 +64,36 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
[editorFields.localFields, pageNumber, currentEnvelopeItem?.id],
|
||||
);
|
||||
|
||||
/**
|
||||
* Debounce the fields used for overlap highlighting so we don't recompute on every
|
||||
* small drag/resize tick. Overlaps only occur within the same page and envelope
|
||||
* item, so computing from this page's fields alone is sufficient.
|
||||
*/
|
||||
const debouncedPageFields = useDebouncedValue(localPageFields, 300);
|
||||
|
||||
const overlappingFieldFormIds = useMemo(() => {
|
||||
const formIds = new Set<string>();
|
||||
|
||||
const pairs = getOverlappingFieldPairs(
|
||||
debouncedPageFields.map((field) => ({
|
||||
id: field.formId,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
})),
|
||||
);
|
||||
|
||||
for (const pair of pairs) {
|
||||
formIds.add(pair.fieldA.id);
|
||||
formIds.add(pair.fieldB.id);
|
||||
}
|
||||
|
||||
return formIds;
|
||||
}, [debouncedPageFields]);
|
||||
|
||||
const handleResizeOrMove = (event: KonvaEventObject<Event>) => {
|
||||
const isDragEvent = event.type === 'dragend';
|
||||
|
||||
@@ -113,6 +145,62 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
pageLayer.current?.batchDraw();
|
||||
};
|
||||
|
||||
/**
|
||||
* Draws (or removes) a dashed warning outline over a field that significantly
|
||||
* overlaps another field. The highlight is a child of the field group so it moves
|
||||
* and resizes with the field, and sits on top of the field's own rect (which is
|
||||
* re-styled on every render and would otherwise clobber a direct stroke change).
|
||||
*/
|
||||
const syncOverlapHighlight = (fieldGroup: Konva.Group, isOverlapping: boolean) => {
|
||||
const existingHighlight = fieldGroup.findOne('.field-overlap-highlight');
|
||||
|
||||
// Skip while a field is actively being dragged/resized. The highlight is driven
|
||||
// by debounced field data, so it would lag behind and distort during the gesture.
|
||||
// It is repainted once the gesture settles (the effect re-runs on isFieldChanging).
|
||||
if (isFieldChanging) {
|
||||
existingHighlight?.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOverlapping) {
|
||||
existingHighlight?.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldRect = fieldGroup.findOne('.field-rect');
|
||||
|
||||
if (!fieldRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightAttrs = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: fieldRect.width(),
|
||||
height: fieldRect.height(),
|
||||
stroke: '#f59e0b',
|
||||
strokeWidth: 2,
|
||||
dash: [6, 4],
|
||||
cornerRadius: 2,
|
||||
strokeScaleEnabled: false,
|
||||
listening: false,
|
||||
} satisfies Partial<Konva.RectConfig>;
|
||||
|
||||
if (existingHighlight instanceof Konva.Rect) {
|
||||
existingHighlight.setAttrs(highlightAttrs);
|
||||
existingHighlight.moveToTop();
|
||||
return;
|
||||
}
|
||||
|
||||
const highlight = new Konva.Rect({
|
||||
name: 'field-overlap-highlight',
|
||||
...highlightAttrs,
|
||||
});
|
||||
|
||||
fieldGroup.add(highlight);
|
||||
highlight.moveToTop();
|
||||
};
|
||||
|
||||
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
|
||||
if (!pageLayer.current) {
|
||||
return;
|
||||
@@ -139,6 +227,8 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
mode: 'edit',
|
||||
});
|
||||
|
||||
syncOverlapHighlight(fieldGroup, overlappingFieldFormIds.has(field.formId));
|
||||
|
||||
if (!isFieldEditable) {
|
||||
return;
|
||||
}
|
||||
@@ -435,7 +525,7 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR
|
||||
interactiveTransformer.current?.forceUpdate();
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields, selectedKonvaFieldGroups]);
|
||||
}, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]);
|
||||
|
||||
const setSelectedFields = (nodes: Konva.Node[]) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||
import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n';
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
type TTextFieldMeta,
|
||||
} from '@documenso/lib/types/field-meta';
|
||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
|
||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -28,7 +30,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
@@ -78,7 +80,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
const { _ } = useLingui();
|
||||
|
||||
@@ -93,6 +95,53 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const selectedField = useMemo(() => structuredClone(editorFields.selectedField), [editorFields.selectedField]);
|
||||
|
||||
/**
|
||||
* Debounce the fields used for overlap detection so we don't recompute on every
|
||||
* small drag/resize movement, which is expensive on large field counts and can
|
||||
* bog down lower-end devices.
|
||||
*/
|
||||
const debouncedLocalFields = useDebouncedValue(editorFields.localFields, 300);
|
||||
|
||||
/**
|
||||
* Fields that significantly overlap each other. Overlapping fields render poorly in
|
||||
* the editor and can behave unexpectedly during signing, so we warn the author here.
|
||||
*/
|
||||
const overlappingFieldPairs = useMemo(
|
||||
() =>
|
||||
getOverlappingFieldPairs(
|
||||
debouncedLocalFields.map((field) => ({
|
||||
id: field.formId,
|
||||
envelopeItemId: field.envelopeItemId,
|
||||
page: field.page,
|
||||
positionX: field.positionX,
|
||||
positionY: field.positionY,
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
})),
|
||||
),
|
||||
[debouncedLocalFields],
|
||||
);
|
||||
|
||||
const handleReviewOverlappingField = () => {
|
||||
const firstPair = overlappingFieldPairs[0];
|
||||
|
||||
if (!firstPair) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetField = editorFields.localFields.find((field) => field.formId === firstPair.fieldA.id);
|
||||
|
||||
if (!targetField) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetField.envelopeItemId !== currentEnvelopeItem?.id) {
|
||||
setCurrentEnvelopeItem(targetField.envelopeItemId);
|
||||
}
|
||||
|
||||
editorFields.setSelectedField(targetField.formId);
|
||||
};
|
||||
|
||||
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
|
||||
if (!selectedField) {
|
||||
return;
|
||||
@@ -211,6 +260,29 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{overlappingFieldPairs.length > 0 && (
|
||||
<Alert
|
||||
variant="warning"
|
||||
className="mt-20 mb-4 flex w-full max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm"
|
||||
>
|
||||
<div className="flex flex-row items-start gap-3">
|
||||
<AlertTriangleIcon className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<AlertTitle>
|
||||
<Trans>Overlapping fields detected</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Some fields are placed on top of each other. This may complicate the signing process or cause
|
||||
fields to not work as expected.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{currentEnvelopeItem !== null ? (
|
||||
<EnvelopePdfViewer
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import {
|
||||
getQuotaUsagePercent,
|
||||
isQuotaExceeded,
|
||||
isQuotaNearing,
|
||||
normalizeCapacityLimit,
|
||||
} from '@documenso/lib/universal/quota-usage';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import type { BadgeProps } from '@documenso/ui/primitives/badge';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Progress } from '@documenso/ui/primitives/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { OrganisationClaim, OrganisationMonthlyStat } from '@prisma/client';
|
||||
import { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { FileIcon, MailIcon, MailOpenIcon, PlugIcon, UsersIcon, UsersRoundIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
import { OrganisationUsageResetButton } from './organisation-usage-reset-button';
|
||||
|
||||
type CapacityUsage = {
|
||||
members: number;
|
||||
teams: number;
|
||||
};
|
||||
|
||||
type UsageRow = {
|
||||
counter: 'document' | 'email' | 'api';
|
||||
label: ReactNode;
|
||||
icon: LucideIcon;
|
||||
used: number;
|
||||
effectiveLimit: number | null;
|
||||
};
|
||||
|
||||
type OrganisationUsagePanelProps = {
|
||||
organisationId: string;
|
||||
monthlyStats: Pick<
|
||||
@@ -15,13 +40,144 @@ type OrganisationUsagePanelProps = {
|
||||
'period' | 'documentCount' | 'emailCount' | 'apiCount' | 'emailReports'
|
||||
>[];
|
||||
organisationClaim: OrganisationClaim;
|
||||
capacityUsage?: CapacityUsage;
|
||||
};
|
||||
|
||||
type UsageCardState = {
|
||||
status: {
|
||||
label: ReactNode;
|
||||
variant: NonNullable<BadgeProps['variant']>;
|
||||
};
|
||||
percent: number;
|
||||
hasFiniteLimit: boolean;
|
||||
progressClassName: string;
|
||||
subtext: ReactNode;
|
||||
};
|
||||
|
||||
type UsageCardStateOptions = {
|
||||
used: number;
|
||||
limit: number | null | undefined;
|
||||
footnote?: ReactNode;
|
||||
};
|
||||
|
||||
const getUsageCardState = ({ used, limit, footnote }: UsageCardStateOptions): UsageCardState => {
|
||||
const percent = getQuotaUsagePercent(used, limit ?? null);
|
||||
const hasFiniteLimit = Boolean(limit && limit > 0);
|
||||
|
||||
if (limit === null || limit === undefined) {
|
||||
return {
|
||||
status: { label: <Trans>Unlimited</Trans>, variant: 'neutral' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (limit === 0) {
|
||||
return {
|
||||
status: { label: <Trans>Blocked</Trans>, variant: 'destructive' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? <Trans>Resource blocked</Trans>,
|
||||
};
|
||||
}
|
||||
|
||||
if (isQuotaExceeded(limit, used)) {
|
||||
return {
|
||||
status: {
|
||||
label: used > limit ? <Trans>Exceeded</Trans> : <Trans>Limit reached</Trans>,
|
||||
variant: 'destructive',
|
||||
},
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '[&>div]:bg-destructive',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isQuotaNearing(limit, used)) {
|
||||
return {
|
||||
status: { label: <Trans>Near limit</Trans>, variant: 'warning' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '[&>div]:bg-yellow-500 dark:[&>div]:bg-yellow-400',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: { label: <Trans>Within limit</Trans>, variant: 'default' },
|
||||
percent,
|
||||
hasFiniteLimit,
|
||||
progressClassName: '',
|
||||
subtext: footnote ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
type UsageStatCardProps = {
|
||||
label: ReactNode;
|
||||
icon: LucideIcon;
|
||||
used: number;
|
||||
limit: number | null | undefined;
|
||||
/** When true the card is a plain counter with no limit, status or progress. */
|
||||
countOnly?: boolean;
|
||||
footnote?: ReactNode;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
const UsageStatCard = ({ label, icon: Icon, used, limit, countOnly = false, footnote, action }: UsageStatCardProps) => {
|
||||
const { status, percent, hasFiniteLimit, progressClassName, subtext } = getUsageCardState({ used, limit, footnote });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border bg-background p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 font-medium text-foreground text-sm">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
|
||||
{!countOnly && (
|
||||
<Badge variant={status.variant} size="small">
|
||||
{status.label}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-1 flex-col">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="font-semibold text-3xl text-foreground tabular-nums tracking-tight">
|
||||
{used.toLocaleString()}
|
||||
</span>
|
||||
{hasFiniteLimit ? (
|
||||
<span className="text-base text-muted-foreground tabular-nums">/ {limit?.toLocaleString()}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasFiniteLimit ? (
|
||||
<span className="font-medium text-muted-foreground text-sm tabular-nums">{percent}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasFiniteLimit ? <Progress className={cn('mt-3 h-2', progressClassName)} value={percent} /> : null}
|
||||
|
||||
{subtext ? <p className="mt-2 text-muted-foreground text-xs">{subtext}</p> : null}
|
||||
</div>
|
||||
|
||||
{action ? <div className="mt-4 flex justify-end border-t pt-4">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganisationUsagePanel = ({
|
||||
organisationId,
|
||||
monthlyStats,
|
||||
organisationClaim,
|
||||
capacityUsage,
|
||||
}: OrganisationUsagePanelProps) => {
|
||||
const monthlyUsagePeriodId = useId();
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<string | undefined>(() => monthlyStats[0]?.period);
|
||||
|
||||
const selectedStat = monthlyStats.find((stat) => stat.period === selectedPeriod) ?? monthlyStats[0];
|
||||
@@ -30,86 +186,105 @@ export const OrganisationUsagePanel = ({
|
||||
// current period), so only offer the reset action when viewing the current month.
|
||||
const isCurrentPeriod = selectedStat?.period === currentMonthlyPeriod();
|
||||
|
||||
const rows = [
|
||||
const capacityRows = capacityUsage
|
||||
? [
|
||||
{
|
||||
key: 'members',
|
||||
label: <Trans>Members</Trans>,
|
||||
icon: UsersIcon,
|
||||
used: capacityUsage.members,
|
||||
limit: normalizeCapacityLimit(organisationClaim.memberCount),
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
label: <Trans>Teams</Trans>,
|
||||
icon: UsersRoundIcon,
|
||||
used: capacityUsage.teams,
|
||||
limit: normalizeCapacityLimit(organisationClaim.teamCount),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const monthlyRows: UsageRow[] = [
|
||||
{
|
||||
counter: 'document' as const,
|
||||
counter: 'document',
|
||||
label: <Trans>Documents</Trans>,
|
||||
icon: FileIcon,
|
||||
used: selectedStat?.documentCount ?? 0,
|
||||
effectiveLimit: organisationClaim.documentQuota,
|
||||
},
|
||||
{
|
||||
counter: 'email' as const,
|
||||
counter: 'email',
|
||||
label: <Trans>Emails</Trans>,
|
||||
icon: MailIcon,
|
||||
used: selectedStat?.emailCount ?? 0,
|
||||
effectiveLimit: organisationClaim.emailQuota,
|
||||
},
|
||||
{
|
||||
counter: 'api' as const,
|
||||
counter: 'api',
|
||||
label: <Trans>API requests</Trans>,
|
||||
icon: PlugIcon,
|
||||
used: selectedStat?.apiCount ?? 0,
|
||||
effectiveLimit: organisationClaim.apiQuota,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-sm">
|
||||
<Trans>Usage for period: {selectedStat?.period || 'N/A'}</Trans>
|
||||
</h3>
|
||||
<div className="mt-4 space-y-6">
|
||||
{capacityRows.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{capacityRows.map((row) => (
|
||||
<UsageStatCard key={row.key} label={row.label} icon={row.icon} used={row.used} limit={row.limit} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{monthlyStats.length > 0 && (
|
||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthlyStats.map((stat) => (
|
||||
<SelectItem key={stat.period} value={stat.period}>
|
||||
{stat.period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 id={monthlyUsagePeriodId} className="font-semibold text-base">
|
||||
<Trans>Monthly usage</Trans>
|
||||
</h3>
|
||||
|
||||
{rows.map((row) => {
|
||||
const percent =
|
||||
row.effectiveLimit && row.effectiveLimit > 0
|
||||
? Math.min(100, Math.round((row.used / row.effectiveLimit) * 100))
|
||||
: 0;
|
||||
{monthlyStats.length > 0 ? (
|
||||
<Select value={selectedStat?.period} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="h-9 w-full sm:w-44" aria-labelledby={monthlyUsagePeriodId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthlyStats.map((stat) => (
|
||||
<SelectItem key={stat.period} value={stat.period}>
|
||||
{stat.period}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={row.counter} className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>{row.label}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{row.used} /{' '}
|
||||
{match(row.effectiveLimit)
|
||||
.with(null, () => <Trans>Unlimited</Trans>)
|
||||
.with(0, () => <Trans>Blocked</Trans>)
|
||||
.otherwise(String)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{monthlyRows.map((row) => (
|
||||
<UsageStatCard
|
||||
key={row.counter}
|
||||
label={row.label}
|
||||
icon={row.icon}
|
||||
used={row.used}
|
||||
limit={row.effectiveLimit}
|
||||
action={
|
||||
selectedStat && isCurrentPeriod ? (
|
||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{row.effectiveLimit && row.effectiveLimit > 0 ? <Progress className="h-2 w-full" value={percent} /> : null}
|
||||
|
||||
{selectedStat && isCurrentPeriod && (
|
||||
<div className="flex w-full justify-end pt-1">
|
||||
<OrganisationUsageResetButton organisationId={organisationId} counter={row.counter} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>
|
||||
<Trans>Reports</Trans>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{selectedStat?.emailReports ?? 0}</span>
|
||||
<UsageStatCard
|
||||
label={<Trans>Reports</Trans>}
|
||||
icon={MailOpenIcon}
|
||||
used={selectedStat?.emailReports ?? 0}
|
||||
limit={null}
|
||||
countOnly
|
||||
footnote={<Trans>Sent this period</Trans>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { RotateCcwIcon } from 'lucide-react';
|
||||
import { useRevalidator } from 'react-router';
|
||||
|
||||
type OrganisationUsageResetButtonProps = {
|
||||
@@ -32,6 +33,7 @@ export const OrganisationUsageResetButton = ({ organisationId, counter }: Organi
|
||||
loading={isPending}
|
||||
onClick={() => reset({ organisationId, counter })}
|
||||
>
|
||||
<RotateCcwIcon className="mr-2 h-3.5 w-3.5" />
|
||||
<Trans>Reset</Trans>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { RATE_LIMIT_WINDOW_REGEX } from '@documenso/lib/types/subscription';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type RateLimitEntryValue = { window: string; max: number };
|
||||
|
||||
@@ -11,50 +13,153 @@ type RateLimitArrayInputProps = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_ENTRY: RateLimitEntryValue = { window: '', max: 0 };
|
||||
|
||||
/** Keep in-progress rows; drop rows that are completely empty. */
|
||||
const persistEntries = (entries: RateLimitEntryValue[]) => {
|
||||
return entries
|
||||
.map((entry) => ({ ...entry, window: entry.window.trim() }))
|
||||
.filter((entry) => entry.window !== '' || entry.max > 0);
|
||||
};
|
||||
|
||||
export const RateLimitArrayInput = ({ value, onChange, disabled }: RateLimitArrayInputProps) => {
|
||||
const entries = value ?? [];
|
||||
const { t } = useLingui();
|
||||
const [draftEntry, setDraftEntry] = useState<RateLimitEntryValue | null>(null);
|
||||
|
||||
const entries = draftEntry ? [...value, draftEntry] : value.length ? value : [EMPTY_ENTRY];
|
||||
|
||||
const getWindowError = (entry: RateLimitEntryValue, index: number) => {
|
||||
const window = entry.window.trim();
|
||||
|
||||
if (window === '' && entry.max <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (window === '') {
|
||||
return t`Enter a window, e.g. 5m`;
|
||||
}
|
||||
|
||||
if (!RATE_LIMIT_WINDOW_REGEX.test(window)) {
|
||||
return t`Use a duration with a unit, e.g. 5m, 1h, or 24h`;
|
||||
}
|
||||
|
||||
const isDuplicateWindow = entries.some((otherEntry, otherIndex) => {
|
||||
return otherIndex !== index && otherEntry.window.trim() === window;
|
||||
});
|
||||
|
||||
return isDuplicateWindow ? t`Use a unique window for each rate limit` : null;
|
||||
};
|
||||
|
||||
const getMaxError = (entry: RateLimitEntryValue) => {
|
||||
if (entry.window.trim() === '' && entry.max <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.max > 0 ? null : t`Enter a max request count greater than 0`;
|
||||
};
|
||||
|
||||
const updateEntry = (index: number, patch: Partial<RateLimitEntryValue>) => {
|
||||
const next = entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||
onChange(next);
|
||||
if (index >= value.length) {
|
||||
const nextDraftEntry = { ...(draftEntry ?? EMPTY_ENTRY), ...patch };
|
||||
const shouldPersistDraft = nextDraftEntry.window.trim() !== '' || nextDraftEntry.max > 0;
|
||||
|
||||
if (shouldPersistDraft) {
|
||||
onChange(persistEntries([...value, nextDraftEntry]));
|
||||
setDraftEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftEntry(nextDraftEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.map((entry, i) => (i === index ? { ...entry, ...patch } : entry));
|
||||
onChange(persistEntries(next));
|
||||
};
|
||||
|
||||
const removeEntry = (index: number) => {
|
||||
onChange(entries.filter((_, i) => i !== index));
|
||||
if (index >= value.length) {
|
||||
setDraftEntry(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.filter((_, i) => i !== index);
|
||||
onChange(persistEntries(next));
|
||||
};
|
||||
|
||||
const addEntry = () => {
|
||||
onChange([...entries, { window: '5m', max: 100 }]);
|
||||
setDraftEntry(EMPTY_ENTRY);
|
||||
};
|
||||
|
||||
const hasErrors = entries.some((entry, index) => getWindowError(entry, index) || getMaxError(entry));
|
||||
const isAddDisabled = disabled || value.length === 0 || Boolean(draftEntry) || hasErrors;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-24"
|
||||
placeholder="5m"
|
||||
value={entry.window}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="w-32"
|
||||
type="number"
|
||||
min={1}
|
||||
value={entry.max}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => removeEntry(index)}>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<span className="w-20 shrink-0">
|
||||
<Trans>Window</Trans>
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
<Trans>Max requests</Trans>
|
||||
</span>
|
||||
<span className="w-9 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="secondary" size="sm" disabled={disabled} onClick={addEntry}>
|
||||
{entries.map((entry, index) => {
|
||||
const windowError = getWindowError(entry, index);
|
||||
const maxError = getMaxError(entry);
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="w-20 shrink-0"
|
||||
placeholder="5m"
|
||||
value={entry.window}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(windowError)}
|
||||
onChange={(e) => updateEntry(index, { window: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="flex-1"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="100"
|
||||
value={entry.max || ''}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(maxError)}
|
||||
onChange={(e) => updateEntry(index, { max: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={disabled}
|
||||
aria-label={t`Remove rate limit`}
|
||||
onClick={() => removeEntry(index)}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{windowError ? <p className="text-destructive text-xs">{windowError}</p> : null}
|
||||
{maxError ? <p className="text-destructive text-xs">{maxError}</p> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-dashed"
|
||||
disabled={isAddDisabled}
|
||||
onClick={addEntry}
|
||||
>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Add rate limit</Trans>
|
||||
<Trans>Add rate limit window</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
EyeIcon,
|
||||
FileOutputIcon,
|
||||
FolderInput,
|
||||
History,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -35,10 +36,10 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { DocumentResendDialog } from '~/components/dialogs/document-resend-dialog';
|
||||
import { EnvelopeCancelDialog } from '~/components/dialogs/envelope-cancel-dialog';
|
||||
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
|
||||
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
|
||||
import { DocumentRecipientLinkCopyDialog } from '~/components/general/document/document-recipient-link-copy-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -95,8 +96,6 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||
|
||||
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-testid="document-table-action-btn">
|
||||
@@ -244,7 +243,25 @@ export const DocumentsTableActionDropdown = ({ row, onMoveDocument }: DocumentsT
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentResendDialog document={row} recipients={nonSignedRecipients} />
|
||||
{canManageDocument && (
|
||||
<EnvelopeRedistributeDialog
|
||||
envelope={{
|
||||
id: row.envelopeId,
|
||||
status: row.status,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
recipients: row.recipients,
|
||||
}}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
<Trans>Resend</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentShareButton
|
||||
documentId={row.id}
|
||||
|
||||
@@ -29,7 +29,7 @@ export type DocumentsTableProps = {
|
||||
data?: TFindDocumentsResponse;
|
||||
isLoading?: boolean;
|
||||
isLoadingError?: boolean;
|
||||
onMoveDocument?: (documentId: number) => void;
|
||||
onMoveDocument?: (envelopeId: string) => void;
|
||||
enableSelection?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
@@ -117,7 +117,7 @@ export const DocumentsTable = ({
|
||||
<DocumentsTableActionButton row={row.original} />
|
||||
<DocumentsTableActionDropdown
|
||||
row={row.original}
|
||||
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.id) : undefined}
|
||||
onMoveDocument={onMoveDocument ? () => onMoveDocument(row.original.envelopeId) : undefined}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useSearchParams } from 'react-router';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
import { TeamMemberDeleteDialog } from '../dialogs/team-member-delete-dialog';
|
||||
import { TeamMemberDeleteDialog, type TeamMemberDeleteDisableReason } from '../dialogs/team-member-delete-dialog';
|
||||
import { TeamMemberUpdateDialog } from '../dialogs/team-member-update-dialog';
|
||||
import { TeamInheritMemberAlert } from '../general/teams/team-inherit-member-alert';
|
||||
|
||||
@@ -86,6 +86,39 @@ export const TeamMembersTable = () => {
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
// A member is a direct team member when they belong to one of the team's
|
||||
// INTERNAL_TEAM groups. Otherwise they are inherited from an organisation or
|
||||
// custom group and cannot be managed directly from this team.
|
||||
const isMemberPartOfInternalTeamGroup = (memberId: string) =>
|
||||
groups.some(
|
||||
(group) =>
|
||||
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
|
||||
group.members.some((member) => member.id === memberId),
|
||||
);
|
||||
|
||||
// Determine why a member can't be removed from the team (if at all). The delete
|
||||
// dialog uses this to explain the reason instead of attempting a removal that
|
||||
// would fail.
|
||||
const getDeleteDisableReason = (member: (typeof results)['data'][number]): TeamMemberDeleteDisableReason | null => {
|
||||
if (organisation.ownerUserId === member.userId) {
|
||||
return 'TEAM_OWNER';
|
||||
}
|
||||
|
||||
if (!isTeamRoleWithinUserHierarchy(team.currentTeamRole, member.teamRole)) {
|
||||
return 'HIGHER_ROLE';
|
||||
}
|
||||
|
||||
if (memberAccessTeamGroup !== undefined) {
|
||||
return 'INHERIT_MEMBER_ENABLED';
|
||||
}
|
||||
|
||||
if (!isMemberPartOfInternalTeamGroup(member.id)) {
|
||||
return 'INHERITED_MEMBER';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
header: _(msg`Team Member`),
|
||||
@@ -111,15 +144,7 @@ export const TeamMembersTable = () => {
|
||||
},
|
||||
{
|
||||
header: _(msg`Source`),
|
||||
cell: ({ row }) => {
|
||||
const internalTeamGroupFound = groups.find(
|
||||
(group) =>
|
||||
group.organisationGroupType === OrganisationGroupType.INTERNAL_TEAM &&
|
||||
group.members.some((member) => member.id === row.original.id),
|
||||
);
|
||||
|
||||
return internalTeamGroupFound ? _(msg`Member`) : _(msg`Group`);
|
||||
},
|
||||
cell: ({ row }) => (isMemberPartOfInternalTeamGroup(row.original.id) ? _(msg`Member`) : _(msg`Group`)),
|
||||
},
|
||||
{
|
||||
header: _(msg`Actions`),
|
||||
@@ -161,16 +186,9 @@ export const TeamMembersTable = () => {
|
||||
memberId={row.original.id}
|
||||
memberName={row.original.name ?? ''}
|
||||
memberEmail={row.original.email}
|
||||
isInheritMemberEnabled={memberAccessTeamGroup !== undefined}
|
||||
disableReason={getDeleteDisableReason(row.original)}
|
||||
trigger={
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
disabled={
|
||||
organisation.ownerUserId === row.original.userId ||
|
||||
!isTeamRoleWithinUserHierarchy(team.currentTeamRole, row.original.teamRole)
|
||||
}
|
||||
title={_(msg`Remove team member`)}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()} title={_(msg`Remove team member`)}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -11,15 +11,15 @@ import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
|
||||
import { Copy, Download, Edit, FolderIcon, MoreHorizontal, Pencil, Share2Icon, Trash2, Upload } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
|
||||
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '../dialogs/envelopes-bulk-move-dialog';
|
||||
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
|
||||
import { TemplateDirectLinkDialog } from '../dialogs/template-direct-link-dialog';
|
||||
import { TemplateMoveToFolderDialog } from '../dialogs/template-move-to-folder-dialog';
|
||||
|
||||
export type TemplatesTableActionDropdownProps = {
|
||||
row: {
|
||||
@@ -44,6 +44,7 @@ export const TemplatesTableActionDropdown = ({
|
||||
onDelete,
|
||||
}: TemplatesTableActionDropdownProps) => {
|
||||
const trpcUtils = trpcReact.useUtils();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [isMoveToFolderDialogOpen, setMoveToFolderDialogOpen] = useState(false);
|
||||
@@ -153,12 +154,13 @@ export const TemplatesTableActionDropdown = ({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
<TemplateMoveToFolderDialog
|
||||
templateId={row.id}
|
||||
templateTitle={row.title}
|
||||
isOpen={isMoveToFolderDialogOpen}
|
||||
<EnvelopesBulkMoveDialog
|
||||
envelopeIds={[row.envelopeId]}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
open={isMoveToFolderDialogOpen}
|
||||
onOpenChange={setMoveToFolderDialogOpen}
|
||||
currentFolderId={row.folderId}
|
||||
currentFolderId={row.folderId ?? undefined}
|
||||
onSuccess={(folderId) => navigate(folderId ? `${templateRootPath}/f/${folderId}` : templateRootPath)}
|
||||
/>
|
||||
|
||||
<EnvelopeRenameDialog
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisa
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
|
||||
import { ZUpdateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/update-admin-organisation.types';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
@@ -30,7 +31,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationMemberRole } from '@prisma/client';
|
||||
import { OrganisationMemberRole, SubscriptionStatus } from '@prisma/client';
|
||||
import { ExternalLinkIcon, InfoIcon, Loader } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
@@ -42,7 +43,6 @@ import { AdminOrganisationDeleteDialog } from '~/components/dialogs/admin-organi
|
||||
import { AdminOrganisationMemberDeleteDialog } from '~/components/dialogs/admin-organisation-member-delete-dialog';
|
||||
import { AdminOrganisationMemberUpdateDialog } from '~/components/dialogs/admin-organisation-member-update-dialog';
|
||||
import { AdminOrganisationSyncSubscriptionDialog } from '~/components/dialogs/admin-organisation-sync-subscription-dialog';
|
||||
import { DetailsCard, DetailsValue } from '~/components/general/admin-details';
|
||||
import { AdminGlobalSettingsSection } from '~/components/general/admin-global-settings-section';
|
||||
import { ClaimLimitFields } from '~/components/general/claim-limit-fields';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
@@ -268,54 +268,32 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<GenericOrganisationAdminForm organisation={organisation} />
|
||||
|
||||
<div className="mt-6 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>Organisation usage</Trans>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Current usage against organisation limits.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsHeader
|
||||
title={t`Organisation usage`}
|
||||
subtitle={t`Current usage against organisation limits.`}
|
||||
className="mt-6"
|
||||
hideDivider
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<DetailsCard label={<Trans>Members</Trans>}>
|
||||
<DetailsValue>
|
||||
{organisation.members.length} /{' '}
|
||||
{organisation.organisationClaim.memberCount === 0
|
||||
? t`Unlimited`
|
||||
: organisation.organisationClaim.memberCount}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Teams</Trans>}>
|
||||
<DetailsValue>
|
||||
{organisation.teams.length} /{' '}
|
||||
{organisation.organisationClaim.teamCount === 0 ? t`Unlimited` : organisation.organisationClaim.teamCount}
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<OrganisationUsagePanel
|
||||
organisationId={organisation.id}
|
||||
monthlyStats={organisation.monthlyStats}
|
||||
organisationClaim={organisation.organisationClaim}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<OrganisationUsagePanel
|
||||
organisationId={organisation.id}
|
||||
monthlyStats={organisation.monthlyStats}
|
||||
organisationClaim={organisation.organisationClaim}
|
||||
capacityUsage={{
|
||||
members: organisation.members.length,
|
||||
teams: organisation.teams.length,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-6 rounded-lg border p-4">
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="global-settings" className="border-b-0">
|
||||
<AccordionTrigger className="py-0">
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-sm">
|
||||
<p className="font-semibold text-base">
|
||||
<Trans>Global Settings</Trans>
|
||||
</p>
|
||||
<p className="mt-1 font-normal text-muted-foreground text-sm">
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Default settings applied to this organisation.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -335,7 +313,15 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
className="mt-16"
|
||||
/>
|
||||
|
||||
<Alert className="my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
|
||||
<Alert
|
||||
className={cn(
|
||||
'my-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center',
|
||||
organisation.subscription?.status === SubscriptionStatus.ACTIVE &&
|
||||
'border border-green-600/20 bg-green-50 dark:border-green-500/20 dark:bg-green-500/10',
|
||||
organisation.subscription?.status === SubscriptionStatus.INACTIVE && 'opacity-60',
|
||||
)}
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Subscription</Trans>
|
||||
@@ -343,7 +329,12 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
{organisation.subscription ? (
|
||||
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{organisation.subscription.status === SubscriptionStatus.ACTIVE && (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-green-600 dark:bg-green-400" aria-hidden="true" />
|
||||
)}
|
||||
<span>{i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription found</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<Trans>No subscription found</Trans>
|
||||
@@ -356,6 +347,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="bg-background"
|
||||
loading={isCreatingStripeCustomer}
|
||||
onClick={async () => createStripeCustomer({ organisationId })}
|
||||
>
|
||||
@@ -366,7 +358,7 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
{organisation.customerId && !organisation.subscription && (
|
||||
<div>
|
||||
<Button variant="outline" asChild>
|
||||
<Button variant="outline" className="bg-background" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/customers/${organisation.customerId}?create=subscription&subscription_default_customer=${organisation.customerId}`}
|
||||
@@ -383,13 +375,13 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
<AdminOrganisationSyncSubscriptionDialog
|
||||
organisationId={organisationId}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Trans>Sync Stripe subscription</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button variant="outline" asChild>
|
||||
<Button variant="outline" className="bg-background" asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
to={`https://dashboard.stripe.com/subscriptions/${organisation.subscription.planId}`}
|
||||
@@ -406,21 +398,27 @@ export default function OrganisationGroupSettingsPage({ params, loaderData }: Ro
|
||||
|
||||
<div className="mt-16 space-y-10">
|
||||
<div>
|
||||
<label className="font-medium text-sm leading-none">
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Organisation Members</Trans>
|
||||
</label>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>People with access to this organisation.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="my-2">
|
||||
<div className="mt-3">
|
||||
<DataTable columns={organisationMembersColumns} data={organisation.members} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-medium text-sm leading-none">
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Organisation Teams</Trans>
|
||||
</label>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Teams that belong to this organisation.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="my-2">
|
||||
<div className="mt-3">
|
||||
<DataTable columns={teamsColumns} data={organisation.teams} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -648,7 +646,7 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
<FormLabel className="flex items-center">
|
||||
<Trans>Inherited subscription claim</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -681,10 +679,15 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input disabled {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div className="rounded-lg border bg-muted/40 px-3 py-2.5 text-sm">
|
||||
{field.value ? (
|
||||
<span className="font-mono text-foreground">{field.value}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
<Trans>No inherited claim</Trans>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -715,108 +718,113 @@ const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdmin
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.teamCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Team Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.teamCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Team Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of teams allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.memberCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Member Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.memberCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Member Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Number of members allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.envelopeItemCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Item Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.envelopeItemCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Envelope Item Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of uploaded files per envelope allowed</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="claims.recipientCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient Count</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<Trans>Maximum number of recipients per document allowed. 0 = Unlimited</Trans>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormLabel>
|
||||
<h3 className="font-semibold text-base">
|
||||
<Trans>Feature Flags</Trans>
|
||||
</FormLabel>
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-sm">
|
||||
<Trans>Capabilities enabled for this organisation.</Trans>
|
||||
</p>
|
||||
|
||||
<div className="mt-2 space-y-2 rounded-md border p-4">
|
||||
<div className="mt-3 space-y-2 rounded-md border p-4">
|
||||
{Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => {
|
||||
const isRestrictedFeature = isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions
|
||||
|
||||
|
||||
@@ -287,7 +287,11 @@ export default function AdminTeamPage({ params }: Route.ComponentProps) {
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mt-4">
|
||||
<AdminGlobalSettingsSection settings={team.teamGlobalSettings} isTeam />
|
||||
<AdminGlobalSettingsSection
|
||||
settings={team.teamGlobalSettings}
|
||||
inheritedSettings={team.organisation.organisationGlobalSettings}
|
||||
isTeam
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -16,10 +16,9 @@ import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, FolderType, OrganisationType } from '@prisma/client';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams, useSearchParams } from 'react-router';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
|
||||
import { EnvelopesBulkCancelDialog } from '~/components/dialogs/envelopes-bulk-cancel-dialog';
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
@@ -55,9 +54,12 @@ export default function DocumentsPage() {
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const documentsPath = formatDocumentsPath(team.url);
|
||||
|
||||
const [isMovingDocument, setIsMovingDocument] = useState(false);
|
||||
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
|
||||
const [documentToMove, setDocumentToMove] = useState<string | null>(null);
|
||||
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>('documents-bulk-selection', {});
|
||||
const [isBulkMoveDialogOpen, setIsBulkMoveDialogOpen] = useState(false);
|
||||
@@ -200,8 +202,8 @@ export default function DocumentsPage() {
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
onMoveDocument={(documentId) => {
|
||||
setDocumentToMove(documentId);
|
||||
onMoveDocument={(envelopeId) => {
|
||||
setDocumentToMove(envelopeId);
|
||||
setIsMovingDocument(true);
|
||||
}}
|
||||
enableSelection
|
||||
@@ -213,8 +215,9 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
|
||||
{documentToMove && (
|
||||
<DocumentMoveToFolderDialog
|
||||
documentId={documentToMove}
|
||||
<EnvelopesBulkMoveDialog
|
||||
envelopeIds={[documentToMove]}
|
||||
envelopeType={EnvelopeType.DOCUMENT}
|
||||
open={isMovingDocument}
|
||||
currentFolderId={folderId}
|
||||
onOpenChange={(open) => {
|
||||
@@ -224,6 +227,9 @@ export default function DocumentsPage() {
|
||||
setDocumentToMove(null);
|
||||
}
|
||||
}}
|
||||
onSuccess={(destinationFolderId) =>
|
||||
navigate(destinationFolderId ? `${documentsPath}/f/${destinationFolderId}` : documentsPath)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test.describe('Redistribute updates recipient send status', () => {
|
||||
let user: User, team: Team, token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
|
||||
// Simulate a recipient that is stuck at NOT_SENT on a pending document
|
||||
// (e.g. the initial send did not dispatch an email for them).
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sentAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${baseUrl}/document/redistribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
recipients: [recipient.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(updatedRecipient.sentAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const rejectRecipient = (
|
||||
request: APIRequestContext,
|
||||
authToken: string,
|
||||
envelopeId: string,
|
||||
recipientId: number,
|
||||
reason: string,
|
||||
actAsEmail?: string,
|
||||
) => {
|
||||
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
recipientId,
|
||||
reason,
|
||||
actAsEmail,
|
||||
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Reject recipient on behalf of', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-reject-recipient',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
|
||||
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirst({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(auditLog).not.toBeNull();
|
||||
|
||||
const auditData = auditLog!.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.recipientId).toBe(recipient.id);
|
||||
expect(auditData.recipientEmail).toBe(recipient.email);
|
||||
expect(auditData.reason).toBe('Declined out of band');
|
||||
expect(auditData.isExternal).toBe(true);
|
||||
|
||||
// No actAsEmail supplied - the rejection defaults to the API user.
|
||||
expect(auditLog!.userId).toBe(user.id);
|
||||
expect(auditLog!.email).toBe(user.email);
|
||||
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
|
||||
const member = await seedTeamMember({ teamId: team.id });
|
||||
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The audit log actor must be the elected member, not the API user.
|
||||
expect(auditLog.userId).toBe(member.id);
|
||||
expect(auditLog.email).toBe(member.email);
|
||||
|
||||
const auditData = auditLog.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.isExternal).toBe(true);
|
||||
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
|
||||
});
|
||||
|
||||
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
|
||||
// A user that exists but belongs to a different team.
|
||||
const { user: outsider } = await seedUser();
|
||||
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(
|
||||
request,
|
||||
token,
|
||||
envelope.id,
|
||||
recipient.id,
|
||||
'Declined out of band',
|
||||
outsider.email,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
// Reject once - succeeds.
|
||||
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
|
||||
expect(firstRes.ok()).toBeTruthy();
|
||||
|
||||
// Reject again - the recipient is no longer NOT_SIGNED.
|
||||
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
|
||||
|
||||
expect(secondRes.ok()).toBeFalsy();
|
||||
expect(secondRes.status()).toBe(400);
|
||||
|
||||
// The original rejection reason must remain unchanged.
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.rejectionReason).toBe('First rejection');
|
||||
});
|
||||
|
||||
test('should not allow rejecting a recipient in another team', async ({ request }) => {
|
||||
// Seed a separate team/user that owns the document.
|
||||
const { user: otherUser, team: otherTeam } = await seedUser();
|
||||
|
||||
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
// Use the original team's token - it must not be able to reject.
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should return 404 for a non-existent recipient', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
|
||||
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
|
||||
|
||||
const recipient = targetEnvelope.recipients[0];
|
||||
|
||||
// Valid recipient ID, but paired with the wrong envelope ID.
|
||||
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
|
||||
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
|
||||
const { team: visTeam, owner } = await seedTeam();
|
||||
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const { token: managerToken } = await createApiToken({
|
||||
userId: manager.id,
|
||||
teamId: visTeam.id,
|
||||
tokenName: 'manager-reject-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// ADMIN-visibility document owned by the team owner.
|
||||
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(
|
||||
request,
|
||||
managerToken,
|
||||
envelope.id,
|
||||
recipient.id,
|
||||
'Should be hidden by visibility',
|
||||
);
|
||||
|
||||
// Visibility failure surfaces as not-found, matching the canonical checks.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
@@ -302,6 +303,95 @@ test.describe('document editor', () => {
|
||||
expect(envelopes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('duplicate document without recipients excludes recipients and fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Seed a draft document that has a recipient with a field.
|
||||
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
|
||||
key: `dup-exclude-recipients-${Date.now()}`,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Open the duplicate dialog.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
|
||||
|
||||
// Uncheck "Include Recipients" — this also disables and unchecks "Include Fields".
|
||||
await page.getByLabel('Include Recipients').click();
|
||||
await expect(page.getByLabel('Include Fields')).toBeDisabled();
|
||||
|
||||
// Duplicate.
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expectToastTextToBeVisible(page, 'Document Duplicated');
|
||||
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
|
||||
|
||||
// The duplicate should have neither recipients nor fields.
|
||||
const duplicate = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
id: { not: document.id },
|
||||
},
|
||||
include: { recipients: true, fields: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(duplicate.recipients).toHaveLength(0);
|
||||
expect(duplicate.fields).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Seed a draft document that has a recipient with a field.
|
||||
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
|
||||
key: `dup-exclude-fields-${Date.now()}`,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Open the duplicate dialog.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
|
||||
|
||||
// Uncheck only "Include Fields" (recipients stay included).
|
||||
await page.getByLabel('Include Fields').click();
|
||||
|
||||
// Duplicate.
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expectToastTextToBeVisible(page, 'Document Duplicated');
|
||||
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
|
||||
|
||||
// The duplicate should keep the recipient but have no fields.
|
||||
const duplicate = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
id: { not: document.id },
|
||||
},
|
||||
include: { recipients: true, fields: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(duplicate.recipients).toHaveLength(1);
|
||||
expect(duplicate.fields).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('download PDF dialog shows envelope items', async ({ page }) => {
|
||||
await openDocumentEnvelopeEditor(page);
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) =>
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
|
||||
await expect(page.getByText('Document resent', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document re-sent');
|
||||
await expectToastTextToBeVisible(page, 'Document resent');
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
/**
|
||||
* Reproduces the "Team has no internal team groups" bug.
|
||||
*
|
||||
* When a team has member inheritance turned OFF, organisation admins/managers are
|
||||
* still inherited into the team as team admins (shown with the "Group" source).
|
||||
* These members are not part of the team's INTERNAL_TEAM group, so they cannot be
|
||||
* removed via the team members page - attempting to do so threw a 500 ("Team has no
|
||||
* internal team groups").
|
||||
*
|
||||
* Instead of crashing, the delete dialog must explain why the inherited member can't
|
||||
* be removed and not offer a confirm button.
|
||||
*/
|
||||
test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => {
|
||||
// Team created with member inheritance OFF.
|
||||
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`;
|
||||
|
||||
// A second organisation admin is inherited into the team as a team admin (source "Group").
|
||||
await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [
|
||||
{
|
||||
name: 'Inherited Admin',
|
||||
email: inheritedAdminEmail,
|
||||
organisationRole: OrganisationMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/members`,
|
||||
});
|
||||
|
||||
const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail });
|
||||
|
||||
// Sanity check: the member is inherited from a group, not a direct team member.
|
||||
await expect(inheritedMemberRow).toBeVisible();
|
||||
await expect(inheritedMemberRow).toContainText('Group');
|
||||
|
||||
await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last());
|
||||
|
||||
// The action stays enabled - opening it shows a dialog explaining why the inherited
|
||||
// member can't be removed, rather than triggering the 500.
|
||||
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
|
||||
await expect(removeMenuItem).toBeEnabled();
|
||||
await removeMenuItem.click();
|
||||
|
||||
await expect(page.getByText('inherited from a group').first()).toBeVisible();
|
||||
|
||||
// No confirm button is offered, so the broken removal can never be triggered.
|
||||
await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Guards against over-disabling the remove action: a direct team member (one that
|
||||
* belongs to the team's INTERNAL_TEAM group) must still be removable.
|
||||
*/
|
||||
test('[TEAMS]: can remove a direct team member', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const directMember = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
name: 'Direct Member',
|
||||
role: TeamMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/members`,
|
||||
});
|
||||
|
||||
const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email });
|
||||
|
||||
await expect(directMemberRow).toBeVisible();
|
||||
|
||||
await openDropdownMenu(page, directMemberRow.getByRole('button').last());
|
||||
|
||||
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
|
||||
|
||||
// The "Remove" action is enabled for direct members and removing them succeeds.
|
||||
await expect(removeMenuItem).toBeEnabled();
|
||||
await removeMenuItem.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible();
|
||||
|
||||
// The member is actually gone after reloading the members list.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0);
|
||||
});
|
||||
@@ -12,12 +12,13 @@
|
||||
"index.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3002 --dir templates",
|
||||
"dev": "react-router dev --config preview/vite.config.ts",
|
||||
"preview:build": "react-router build --config preview/vite.config.ts",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/nodemailer-resend": "4.0.0",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@react-email/body": "0.2.0",
|
||||
"@react-email/button": "0.2.0",
|
||||
"@react-email/code-block": "0.2.0",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/.react-router/
|
||||
/build/
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import type { FieldConfig } from '../lib/templates';
|
||||
import { templates } from '../lib/templates';
|
||||
import { viewports } from '../lib/viewports';
|
||||
import { PropFields } from './prop-fields';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const;
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
en: 'English',
|
||||
de: 'German',
|
||||
fr: 'French',
|
||||
es: 'Spanish',
|
||||
it: 'Italian',
|
||||
nl: 'Dutch',
|
||||
pl: 'Polish',
|
||||
'pt-BR': 'Portuguese (Brazil)',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
zh: 'Chinese',
|
||||
};
|
||||
|
||||
const DEFAULT_COLORS = {
|
||||
primary: '#a2e771',
|
||||
primaryForeground: '#162c07',
|
||||
background: '#ffffff',
|
||||
foreground: '#0f172a',
|
||||
};
|
||||
|
||||
type PlaygroundProps = {
|
||||
slug: string;
|
||||
fields: Record<string, FieldConfig>;
|
||||
defaultProps: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [props, setProps] = useState(defaultProps);
|
||||
const [html, setHtml] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
const [viewportIndex, setViewportIndex] = useState(2);
|
||||
const [lang, setLang] = useState('en');
|
||||
|
||||
const [brandingEnabled, setBrandingEnabled] = useState(false);
|
||||
const [colors, setColors] = useState(DEFAULT_COLORS);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const entries = Object.entries(templates);
|
||||
|
||||
return GROUP_ORDER.map((group) => ({
|
||||
group,
|
||||
entries: entries.filter(([, def]) => def.group === group),
|
||||
})).filter((section) => section.entries.length > 0);
|
||||
}, []);
|
||||
|
||||
const fetchHtml = useCallback(
|
||||
async (currentProps: Record<string, unknown>, currentLang: string, brandColors: typeof colors | null) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
props: currentProps,
|
||||
lang: currentLang,
|
||||
colors: brandColors,
|
||||
assetBaseUrl: window.location.origin,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setHtml(await response.text());
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[slug],
|
||||
);
|
||||
|
||||
// Reset props when navigating to a different template.
|
||||
useEffect(() => {
|
||||
setProps(defaultProps);
|
||||
}, [defaultProps]);
|
||||
|
||||
// Re-render on any input change (debounced).
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void fetchHtml(props, lang, brandingEnabled ? colors : null);
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, [props, lang, brandingEnabled, colors, fetchHtml]);
|
||||
|
||||
const handlePropChange = (key: string, value: unknown) => {
|
||||
setProps((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleColorChange = (key: keyof typeof colors, value: string) => {
|
||||
setColors((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Force dark mode inside the iframe by neutralising the prefers-color-scheme
|
||||
// media query (color-scheme alone doesn't trigger it inside an iframe).
|
||||
const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html;
|
||||
|
||||
const viewport = viewports[viewportIndex];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-neutral-100 font-sans text-neutral-900">
|
||||
{/* Sidebar */}
|
||||
<aside className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white">
|
||||
<div className="border-neutral-200 border-b px-4 py-3">
|
||||
<h1 className="font-semibold text-sm">Email Preview</h1>
|
||||
<p className="text-neutral-500 text-xs">{Object.keys(templates).length} templates</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-2 py-2">
|
||||
{groupedTemplates.map((section) => (
|
||||
<div key={section.group} className="mb-3">
|
||||
<div className="px-2 py-1 font-medium text-neutral-400 text-xs uppercase tracking-wide">
|
||||
{section.group}
|
||||
</div>
|
||||
|
||||
{section.entries.map(([id, def]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/${id}`)}
|
||||
className={`block w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
slug === id ? 'bg-neutral-900 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{def.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Props panel */}
|
||||
<section className="flex h-full w-72 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white px-4 py-3">
|
||||
<h2 className="mb-3 font-medium text-neutral-500 text-xs uppercase tracking-wide">Props</h2>
|
||||
<PropFields fields={fields} values={props} onChange={handlePropChange} />
|
||||
</section>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<Toolbar
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
viewportIndex={viewportIndex}
|
||||
setViewportIndex={setViewportIndex}
|
||||
lang={lang}
|
||||
setLang={setLang}
|
||||
brandingEnabled={brandingEnabled}
|
||||
setBrandingEnabled={setBrandingEnabled}
|
||||
colors={colors}
|
||||
onColorChange={handleColorChange}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`flex flex-1 items-start justify-center overflow-auto p-6 ${
|
||||
theme === 'dark' ? 'bg-neutral-800' : 'bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 overflow-hidden rounded-lg bg-white shadow-lg"
|
||||
style={{ width: viewport.width }}
|
||||
>
|
||||
<iframe
|
||||
title={`${viewport.name} ${theme}`}
|
||||
srcDoc={displayHtml}
|
||||
className="h-[calc(100vh-8rem)] w-full border-0"
|
||||
style={{ colorScheme: theme }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ToolbarProps = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
viewportIndex: number;
|
||||
setViewportIndex: (index: number) => void;
|
||||
lang: string;
|
||||
setLang: (lang: string) => void;
|
||||
brandingEnabled: boolean;
|
||||
setBrandingEnabled: (enabled: boolean) => void;
|
||||
colors: typeof DEFAULT_COLORS;
|
||||
onColorChange: (key: keyof typeof DEFAULT_COLORS, value: string) => void;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
const Toolbar = (props: ToolbarProps) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 border-neutral-200 border-b bg-white px-4 py-2">
|
||||
<SegmentedControl
|
||||
label="Theme"
|
||||
value={props.theme}
|
||||
options={[
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
]}
|
||||
onChange={(value) => props.setTheme(value as Theme)}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
label="Viewport"
|
||||
value={String(props.viewportIndex)}
|
||||
options={viewports.map((viewport, index) => ({ value: String(index), label: viewport.name }))}
|
||||
onChange={(value) => props.setViewportIndex(Number(value))}
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
|
||||
<span className="font-medium">Language</span>
|
||||
<select
|
||||
value={props.lang}
|
||||
onChange={(event) => props.setLang(event.target.value)}
|
||||
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs"
|
||||
>
|
||||
{SUPPORTED_LANGUAGE_CODES.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{LANGUAGE_LABELS[code] ?? code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.brandingEnabled}
|
||||
onChange={(event) => props.setBrandingEnabled(event.target.checked)}
|
||||
/>
|
||||
<span className="font-medium">Brand colours</span>
|
||||
</label>
|
||||
|
||||
{props.brandingEnabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorInput
|
||||
label="Primary"
|
||||
value={props.colors.primary}
|
||||
onChange={(value) => props.onColorChange('primary', value)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="On primary"
|
||||
value={props.colors.primaryForeground}
|
||||
onChange={(value) => props.onColorChange('primaryForeground', value)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Background"
|
||||
value={props.colors.background}
|
||||
onChange={(value) => props.onColorChange('background', value)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Text"
|
||||
value={props.colors.foreground}
|
||||
onChange={(value) => props.onColorChange('foreground', value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="ml-auto text-neutral-400 text-xs">{props.loading ? 'Rendering…' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SegmentedControlProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const SegmentedControl = (props: SegmentedControlProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-neutral-600 text-xs">{props.label}</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-neutral-300">
|
||||
{props.options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => props.onChange(option.value)}
|
||||
className={`px-2.5 py-1 text-xs transition-colors ${
|
||||
props.value === option.value
|
||||
? 'bg-neutral-900 text-white'
|
||||
: 'bg-white text-neutral-700 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ColorInputProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const ColorInput = (props: ColorInputProps) => {
|
||||
return (
|
||||
<label className="flex items-center gap-1 text-neutral-600 text-xs">
|
||||
<span>{props.label}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
className="h-6 w-6 cursor-pointer rounded border border-neutral-300 bg-white p-0"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { FieldConfig } from '../lib/templates';
|
||||
|
||||
type PropFieldsProps = {
|
||||
fields: Record<string, FieldConfig>;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const PropFields = ({ fields, values, onChange }: PropFieldsProps) => {
|
||||
const entries = Object.entries(fields);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-neutral-400 text-xs">No editable props.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{entries.map(([key, field]) => (
|
||||
<PropField key={key} name={key} field={field} value={values[key]} onChange={(value) => onChange(key, value)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PropFieldProps = {
|
||||
name: string;
|
||||
field: FieldConfig;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs focus:border-neutral-500 focus:outline-none';
|
||||
|
||||
const PropField = ({ name, field, value, onChange }: PropFieldProps) => {
|
||||
const id = `prop-${name}`;
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<label htmlFor={id} className="font-medium text-neutral-600 text-xs">
|
||||
{field.label}
|
||||
</label>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<input
|
||||
id={id}
|
||||
className={inputClass}
|
||||
value={String(value ?? '')}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'textarea' && (
|
||||
<textarea
|
||||
id={id}
|
||||
className={`${inputClass} min-h-16 resize-y font-mono`}
|
||||
value={String(value ?? '')}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'number' && (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
className={inputClass}
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' && (
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'list' && (
|
||||
<textarea
|
||||
id={id}
|
||||
className={`${inputClass} min-h-16 resize-y font-mono`}
|
||||
value={Array.isArray(value) ? value.join('\n') : ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value === '' ? [] : event.target.value.split('\n'))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'select' && field.options && (
|
||||
<select
|
||||
id={id}
|
||||
className={inputClass}
|
||||
value={String(value ?? '')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{field.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{field.description && <p className="text-neutral-400 text-xs">{field.description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
|
||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import { AccessAuth2FAEmailTemplate } from '../../../templates/access-auth-2fa';
|
||||
import { AdminUserCreatedTemplate } from '../../../templates/admin-user-created';
|
||||
import { BulkSendCompleteEmail } from '../../../templates/bulk-send-complete';
|
||||
import { ConfirmEmailTemplate } from '../../../templates/confirm-email';
|
||||
import { ConfirmTeamEmailTemplate } from '../../../templates/confirm-team-email';
|
||||
import { DocumentCancelTemplate } from '../../../templates/document-cancel';
|
||||
import { DocumentCompletedEmailTemplate } from '../../../templates/document-completed';
|
||||
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '../../../templates/document-created-from-direct-template';
|
||||
import { DocumentInviteEmailTemplate } from '../../../templates/document-invite';
|
||||
import { DocumentPendingEmailTemplate } from '../../../templates/document-pending';
|
||||
import { DocumentRecipientSignedEmailTemplate } from '../../../templates/document-recipient-signed';
|
||||
import { DocumentRejectedEmail } from '../../../templates/document-rejected';
|
||||
import { DocumentRejectionConfirmedEmail } from '../../../templates/document-rejection-confirmed';
|
||||
import { DocumentReminderEmailTemplate } from '../../../templates/document-reminder';
|
||||
import { DocumentSelfSignedEmailTemplate } from '../../../templates/document-self-signed';
|
||||
import { DocumentSuperDeleteEmailTemplate } from '../../../templates/document-super-delete';
|
||||
import { ForgotPasswordTemplate } from '../../../templates/forgot-password';
|
||||
import { OrganisationAccountLinkConfirmationTemplate } from '../../../templates/organisation-account-link-confirmation';
|
||||
import { OrganisationDeleteEmailTemplate } from '../../../templates/organisation-delete';
|
||||
import { OrganisationInviteEmailTemplate } from '../../../templates/organisation-invite';
|
||||
import { OrganisationJoinEmailTemplate } from '../../../templates/organisation-join';
|
||||
import { OrganisationLeaveEmailTemplate } from '../../../templates/organisation-leave';
|
||||
import { OrganisationLimitAlertEmailTemplate } from '../../../templates/organisation-limit-alert';
|
||||
import { RecipientExpiredTemplate } from '../../../templates/recipient-expired';
|
||||
import { RecipientRemovedFromDocumentTemplate } from '../../../templates/recipient-removed-from-document';
|
||||
import { ResetPasswordTemplate } from '../../../templates/reset-password';
|
||||
import { TeamDeleteEmailTemplate } from '../../../templates/team-delete';
|
||||
import { TeamEmailRemovedTemplate } from '../../../templates/team-email-removed';
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'list';
|
||||
|
||||
export type FieldConfig = {
|
||||
type: FieldType;
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
default: unknown;
|
||||
options?: { label: string; value: string }[];
|
||||
};
|
||||
|
||||
export type TemplateDefinition = {
|
||||
/** Human label for the sidebar. */
|
||||
name: string;
|
||||
/** Loose grouping for the sidebar. */
|
||||
group: 'Documents' | 'Recipients' | 'Organisations' | 'Teams' | 'Account' | 'Admin';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ComponentType<any>;
|
||||
/** Editable props surfaced in the preview UI. */
|
||||
fields: Record<string, FieldConfig>;
|
||||
};
|
||||
|
||||
// --- Reusable field presets ---
|
||||
|
||||
const documentNameField: FieldConfig = {
|
||||
type: 'text',
|
||||
label: 'Document name',
|
||||
default: 'Open Source Pledge.pdf',
|
||||
};
|
||||
|
||||
const recipientNameField: FieldConfig = {
|
||||
type: 'text',
|
||||
label: 'Recipient name',
|
||||
default: 'Lucas Smith',
|
||||
};
|
||||
|
||||
const roleField: FieldConfig = {
|
||||
type: 'select',
|
||||
label: 'Recipient role',
|
||||
default: 'SIGNER',
|
||||
options: [
|
||||
{ label: 'Signer', value: 'SIGNER' },
|
||||
{ label: 'Viewer', value: 'VIEWER' },
|
||||
{ label: 'Approver', value: 'APPROVER' },
|
||||
{ label: 'CC', value: 'CC' },
|
||||
{ label: 'Assistant', value: 'ASSISTANT' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit template registry. Each entry maps a slug → component + editable
|
||||
* `fields`. The slug is the route param (`/:slug`) and matches the source
|
||||
* filename (sans extension).
|
||||
*
|
||||
* `fields` drives both the default preview values AND the editable inputs in
|
||||
* the UI, so production templates stay free of preview-only defaults.
|
||||
*/
|
||||
export const templates: Record<string, TemplateDefinition> = {
|
||||
// ---- Documents ----
|
||||
'document-invite': {
|
||||
name: 'Document invite',
|
||||
group: 'Documents',
|
||||
component: DocumentInviteEmailTemplate,
|
||||
fields: {
|
||||
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
|
||||
inviterEmail: { type: 'text', label: 'Inviter email', default: 'lucas@documenso.com' },
|
||||
documentName: documentNameField,
|
||||
role: roleField,
|
||||
customBody: {
|
||||
type: 'textarea',
|
||||
label: 'Custom message',
|
||||
default: '',
|
||||
description: 'Leave blank to use the default invite copy.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'document-completed': {
|
||||
name: 'Document completed',
|
||||
group: 'Documents',
|
||||
component: DocumentCompletedEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
customBody: { type: 'textarea', label: 'Custom message', default: '' },
|
||||
},
|
||||
},
|
||||
'document-self-signed': {
|
||||
name: 'Document self-signed',
|
||||
group: 'Documents',
|
||||
component: DocumentSelfSignedEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
'document-pending': {
|
||||
name: 'Document pending',
|
||||
group: 'Documents',
|
||||
component: DocumentPendingEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
'document-reminder': {
|
||||
name: 'Document reminder',
|
||||
group: 'Documents',
|
||||
component: DocumentReminderEmailTemplate,
|
||||
fields: {
|
||||
recipientName: recipientNameField,
|
||||
documentName: documentNameField,
|
||||
role: roleField,
|
||||
customBody: { type: 'textarea', label: 'Custom message', default: '' },
|
||||
},
|
||||
},
|
||||
'document-cancel': {
|
||||
name: 'Document cancelled',
|
||||
group: 'Documents',
|
||||
component: DocumentCancelTemplate,
|
||||
fields: {
|
||||
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
|
||||
documentName: documentNameField,
|
||||
cancellationReason: {
|
||||
type: 'textarea',
|
||||
label: 'Cancellation reason',
|
||||
default: '',
|
||||
description: 'Optional. Blank renders no reason block.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'document-rejected': {
|
||||
name: 'Document rejected',
|
||||
group: 'Documents',
|
||||
component: DocumentRejectedEmail,
|
||||
fields: {
|
||||
recipientName: recipientNameField,
|
||||
documentName: documentNameField,
|
||||
documentUrl: { type: 'text', label: 'Document URL', default: 'https://documenso.com' },
|
||||
rejectionReason: {
|
||||
type: 'textarea',
|
||||
label: 'Rejection reason',
|
||||
default: 'The pledge amount is incorrect.',
|
||||
description: 'Optional in production; blank renders no reason block.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'document-rejection-confirmed': {
|
||||
name: 'Document rejection confirmed',
|
||||
group: 'Documents',
|
||||
component: DocumentRejectionConfirmedEmail,
|
||||
fields: {
|
||||
recipientName: recipientNameField,
|
||||
documentName: documentNameField,
|
||||
documentOwnerName: { type: 'text', label: 'Document owner', default: 'Timur Ercan' },
|
||||
reason: {
|
||||
type: 'textarea',
|
||||
label: 'Rejection reason',
|
||||
default: 'The pledge amount is incorrect.',
|
||||
description: 'Optional in production; blank renders no reason block.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'document-created-from-direct-template': {
|
||||
name: 'Document created (direct template)',
|
||||
group: 'Documents',
|
||||
component: DocumentCreatedFromDirectTemplateEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
'document-super-delete': {
|
||||
name: 'Document deleted (admin)',
|
||||
group: 'Documents',
|
||||
component: DocumentSuperDeleteEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
'bulk-send-complete': {
|
||||
name: 'Bulk send complete',
|
||||
group: 'Documents',
|
||||
component: BulkSendCompleteEmail,
|
||||
fields: {
|
||||
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
|
||||
templateName: { type: 'text', label: 'Template name', default: 'NDA Template' },
|
||||
totalProcessed: { type: 'number', label: 'Total processed', default: 50 },
|
||||
successCount: { type: 'number', label: 'Success count', default: 48 },
|
||||
failedCount: { type: 'number', label: 'Failed count', default: 2 },
|
||||
errors: {
|
||||
type: 'list',
|
||||
label: 'Errors',
|
||||
default: ['Row 12: invalid email', 'Row 30: missing name'],
|
||||
description: 'One error per line. Rendered when failed count > 0.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Recipients ----
|
||||
'document-recipient-signed': {
|
||||
name: 'Recipient signed',
|
||||
group: 'Recipients',
|
||||
component: DocumentRecipientSignedEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
recipientName: recipientNameField,
|
||||
},
|
||||
},
|
||||
'recipient-expired': {
|
||||
name: 'Recipient expired',
|
||||
group: 'Recipients',
|
||||
component: RecipientExpiredTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
recipientName: recipientNameField,
|
||||
},
|
||||
},
|
||||
'recipient-removed-from-document': {
|
||||
name: 'Recipient removed',
|
||||
group: 'Recipients',
|
||||
component: RecipientRemovedFromDocumentTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Organisations ----
|
||||
'organisation-invite': {
|
||||
name: 'Organisation invite',
|
||||
group: 'Organisations',
|
||||
component: OrganisationInviteEmailTemplate,
|
||||
fields: {
|
||||
senderName: { type: 'text', label: 'Sender name', default: 'Lucas Smith' },
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-join': {
|
||||
name: 'Organisation join',
|
||||
group: 'Organisations',
|
||||
component: OrganisationJoinEmailTemplate,
|
||||
fields: {
|
||||
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-leave': {
|
||||
name: 'Organisation leave',
|
||||
group: 'Organisations',
|
||||
component: OrganisationLeaveEmailTemplate,
|
||||
fields: {
|
||||
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-delete': {
|
||||
name: 'Organisation delete',
|
||||
group: 'Organisations',
|
||||
component: OrganisationDeleteEmailTemplate,
|
||||
fields: {
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-limit-alert': {
|
||||
name: 'Organisation limit alert',
|
||||
group: 'Organisations',
|
||||
component: OrganisationLimitAlertEmailTemplate,
|
||||
fields: {
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-account-link-confirmation': {
|
||||
name: 'Account link confirmation',
|
||||
group: 'Organisations',
|
||||
component: OrganisationAccountLinkConfirmationTemplate,
|
||||
fields: {
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Teams ----
|
||||
'confirm-team-email': {
|
||||
name: 'Confirm team email',
|
||||
group: 'Teams',
|
||||
component: ConfirmTeamEmailTemplate,
|
||||
fields: {
|
||||
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'team-delete': {
|
||||
name: 'Team delete',
|
||||
group: 'Teams',
|
||||
component: TeamDeleteEmailTemplate,
|
||||
fields: {},
|
||||
},
|
||||
'team-email-removed': {
|
||||
name: 'Team email removed',
|
||||
group: 'Teams',
|
||||
component: TeamEmailRemovedTemplate,
|
||||
fields: {
|
||||
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
|
||||
teamEmail: { type: 'text', label: 'Team email', default: 'team@documenso.com' },
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Account ----
|
||||
'confirm-email': {
|
||||
name: 'Confirm email',
|
||||
group: 'Account',
|
||||
component: ConfirmEmailTemplate,
|
||||
fields: {
|
||||
confirmationLink: {
|
||||
type: 'text',
|
||||
label: 'Confirmation link',
|
||||
default: 'https://documenso.com/confirm',
|
||||
},
|
||||
},
|
||||
},
|
||||
'forgot-password': {
|
||||
name: 'Forgot password',
|
||||
group: 'Account',
|
||||
component: ForgotPasswordTemplate,
|
||||
fields: {
|
||||
resetPasswordLink: {
|
||||
type: 'text',
|
||||
label: 'Reset link',
|
||||
default: 'https://documenso.com/reset',
|
||||
},
|
||||
},
|
||||
},
|
||||
'reset-password': {
|
||||
name: 'Reset password',
|
||||
group: 'Account',
|
||||
component: ResetPasswordTemplate,
|
||||
fields: {
|
||||
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
|
||||
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
|
||||
},
|
||||
},
|
||||
'access-auth-2fa': {
|
||||
name: 'Access auth 2FA',
|
||||
group: 'Account',
|
||||
component: AccessAuth2FAEmailTemplate,
|
||||
fields: {
|
||||
documentTitle: { type: 'text', label: 'Document title', default: 'Open Source Pledge.pdf' },
|
||||
code: { type: 'text', label: 'Code', default: '123456' },
|
||||
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
|
||||
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
|
||||
expiresInMinutes: { type: 'number', label: 'Expires in (min)', default: 10 },
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Admin ----
|
||||
'admin-user-created': {
|
||||
name: 'Admin user created',
|
||||
group: 'Admin',
|
||||
component: AdminUserCreatedTemplate,
|
||||
fields: {
|
||||
resetPasswordLink: {
|
||||
type: 'text',
|
||||
label: 'Reset link',
|
||||
default: 'https://documenso.com/reset',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type TemplateId = keyof typeof templates;
|
||||
|
||||
/** Extract the default prop values from a template's field config. */
|
||||
export const getDefaultProps = (fields: Record<string, FieldConfig>): Record<string, unknown> => {
|
||||
const props: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
props[key] = field.default;
|
||||
}
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
export const getTemplate = (slug: string): TemplateDefinition | undefined => templates[slug];
|
||||
@@ -0,0 +1,10 @@
|
||||
export type Viewport = {
|
||||
name: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const viewports: Viewport[] = [
|
||||
{ name: 'Mobile', width: 390 },
|
||||
{ name: 'Tablet', width: 768 },
|
||||
{ name: 'Desktop', width: 1024 },
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
import stylesheet from './app.css?url';
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { index, type RouteConfig, route } from '@react-router/dev/routes';
|
||||
|
||||
export default [
|
||||
index('routes/_index.tsx'),
|
||||
route('api/render', 'routes/api.render.tsx'),
|
||||
route(':slug', 'routes/$slug.tsx'),
|
||||
] satisfies RouteConfig;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { data } from 'react-router';
|
||||
|
||||
import { EmailPlayground } from '../components/playground';
|
||||
import { getDefaultProps, getTemplate } from '../lib/templates';
|
||||
import type { Route } from './+types/$slug';
|
||||
|
||||
export const loader = ({ params }: Route.LoaderArgs) => {
|
||||
const { slug } = params;
|
||||
const template = getTemplate(slug);
|
||||
|
||||
if (!template) {
|
||||
throw data(`Unknown template: ${slug}`, { status: 404 });
|
||||
}
|
||||
|
||||
return {
|
||||
slug,
|
||||
templateName: template.name,
|
||||
fields: template.fields,
|
||||
defaultProps: getDefaultProps(template.fields),
|
||||
};
|
||||
};
|
||||
|
||||
export const meta = ({ data: loaderData }: Route.MetaArgs) => {
|
||||
if (!loaderData) {
|
||||
return [{ title: 'Not found — Email Preview' }];
|
||||
}
|
||||
|
||||
return [{ title: `${loaderData.templateName} — Email Preview` }];
|
||||
};
|
||||
|
||||
const TemplatePage = ({ loaderData }: Route.ComponentProps) => {
|
||||
return <EmailPlayground slug={loaderData.slug} fields={loaderData.fields} defaultProps={loaderData.defaultProps} />;
|
||||
};
|
||||
|
||||
export default TemplatePage;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
import { templates } from '../lib/templates';
|
||||
|
||||
/**
|
||||
* The index has no UI of its own — redirect to the first template so the
|
||||
* preview always opens on something.
|
||||
*/
|
||||
export const loader = () => {
|
||||
const firstSlug = Object.keys(templates)[0];
|
||||
|
||||
return redirect(`/${firstSlug}`);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
|
||||
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
|
||||
|
||||
import { getTemplate } from '../lib/templates';
|
||||
import type { Route } from './+types/api.render';
|
||||
|
||||
type RenderRequestBody = {
|
||||
slug: string;
|
||||
props: Record<string, unknown>;
|
||||
lang?: string;
|
||||
colors?: Record<string, string> | null;
|
||||
assetBaseUrl: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/render — render an email template to HTML via the REAL production
|
||||
* pipeline (`renderEmailWithI18N`), so i18n and brand-colour injection match a
|
||||
* live send. Returns `text/html` for the client to drop into an iframe srcDoc.
|
||||
*/
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const body = (await request.json()) as RenderRequestBody;
|
||||
|
||||
const template = getTemplate(body.slug);
|
||||
|
||||
if (!template) {
|
||||
return new Response(JSON.stringify({ error: `Unknown template: ${body.slug}` }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve brand colours through the same resolver production uses, so the
|
||||
// preview applies the same per-token fallbacks as a live send.
|
||||
const brandingColors =
|
||||
body.colors && Object.keys(body.colors).length > 0 ? resolveEmailBrandingColors(body.colors) : null;
|
||||
|
||||
const Component = template.component;
|
||||
const element = <Component {...body.props} assetBaseUrl={body.assetBaseUrl} />;
|
||||
|
||||
const html = await renderEmailWithI18N(element, {
|
||||
lang: body.lang ?? 'en',
|
||||
branding: brandingColors
|
||||
? {
|
||||
brandingEnabled: true,
|
||||
brandingUrl: '',
|
||||
brandingLogo: '',
|
||||
brandingCompanyDetails: '',
|
||||
brandingHidePoweredBy: false,
|
||||
brandingColors,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: { config: './tailwind.config.cjs' },
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Config } from '@react-router/dev/config';
|
||||
|
||||
export default {
|
||||
appDirectory: 'app',
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,24 @@
|
||||
const path = require('node:path');
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [path.join(__dirname, 'app/**/*.{ts,tsx}')],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'ui-sans-serif',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Helvetica Neue',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"include": ["**/*", ".react-router/types/**/*"],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@documenso/email/*": ["../*"],
|
||||
"@documenso/lib": ["../../lib"],
|
||||
"@documenso/lib/*": ["../../lib/*"],
|
||||
"@documenso/prisma": ["../../prisma"],
|
||||
"@documenso/tailwind-config": ["../../tailwind-config"],
|
||||
"@documenso/ui": ["../../ui"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"useUnknownInCatchVariables": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import path from 'node:path';
|
||||
import { lingui } from '@lingui/vite-plugin';
|
||||
import { reactRouter } from '@react-router/dev/vite';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import { defineConfig } from 'vite';
|
||||
import macrosPlugin from 'vite-plugin-babel-macros';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
/**
|
||||
* Standalone Vite app for previewing Documenso emails.
|
||||
*
|
||||
* Emails render server-side through the real `renderEmailWithI18N` pipeline
|
||||
* (see `app/routes/preview.tsx`), so the SSR config mirrors the main Remix app:
|
||||
* Prisma, the tailwind config, and native modules stay external.
|
||||
*/
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss(path.join(__dirname, 'tailwind.config.cjs')), autoprefixer],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: parseInt(process.env.PORT || '3002', 10),
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
// Serve the email static assets (logo, icons) under `/static` so templates'
|
||||
// `assetBaseUrl="/static"` resolves to the same images production uses.
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: path.join(__dirname, '../static') + '/*',
|
||||
dest: 'static',
|
||||
},
|
||||
],
|
||||
}),
|
||||
reactRouter(),
|
||||
macrosPlugin(),
|
||||
lingui(),
|
||||
tsconfigPaths(),
|
||||
],
|
||||
ssr: {
|
||||
noExternal: ['@documenso/email'],
|
||||
external: [
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'@prisma/client',
|
||||
'@documenso/tailwind-config',
|
||||
'playwright',
|
||||
'playwright-core',
|
||||
'@playwright/browser-chromium',
|
||||
'pdfjs-dist',
|
||||
'@google-cloud/kms',
|
||||
'@google-cloud/secret-manager',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@napi-rs/canvas',
|
||||
'@node-rs/bcrypt',
|
||||
'sharp',
|
||||
'playwright',
|
||||
'playwright-core',
|
||||
'@playwright/browser-chromium',
|
||||
'lightningcss',
|
||||
'fsevents',
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { EmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
type BrandingContextValue = {
|
||||
@@ -6,6 +7,7 @@ type BrandingContextValue = {
|
||||
brandingLogo: string;
|
||||
brandingCompanyDetails: string;
|
||||
brandingHidePoweredBy: boolean;
|
||||
brandingColors?: EmailBrandingColors;
|
||||
};
|
||||
|
||||
const BrandingContext = createContext<BrandingContextValue | undefined>(undefined);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import config from '@documenso/tailwind-config';
|
||||
import { DEFAULT_BRAND_COLORS } from '@documenso/lib/constants/theme';
|
||||
import type { EmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
|
||||
import { resolveEmailBrandingColors } from '@documenso/lib/utils/email-branding-colors';
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import * as ReactEmail from '@react-email/render';
|
||||
@@ -11,19 +13,62 @@ export type RenderOptions = ReactEmail.Options & {
|
||||
i18n?: I18n;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const colors = (config.theme?.extend?.colors || {}) as Record<string, string>;
|
||||
/**
|
||||
* The default email token set: the shadcn theme tokens, sourced as hex from
|
||||
* `DEFAULT_BRAND_COLORS` (which mirrors `theme.css`). Emails can't use CSS
|
||||
* variables, so these are concrete hex values baked into the Tailwind config.
|
||||
*
|
||||
* Resolved through the same `resolveEmailBrandingColors` pipeline as tenant
|
||||
* colours so the default values live in exactly one place (`DEFAULT_BRAND_COLORS`)
|
||||
* and the default + tenant paths can't drift. Used when a tenant has no
|
||||
* (entitled) brand colours.
|
||||
*/
|
||||
const DEFAULT_EMAIL_BRANDING_COLORS: EmailBrandingColors =
|
||||
resolveEmailBrandingColors(DEFAULT_BRAND_COLORS) ?? DEFAULT_BRAND_COLORS;
|
||||
|
||||
/**
|
||||
* Map the resolved colour set to flat semantic Tailwind tokens. Templates use
|
||||
* these directly (`bg-primary`, `text-muted-foreground`, `border-border`, …),
|
||||
* mirroring the app's shadcn tokens, instead of bespoke `slate-*`/`documenso-*`
|
||||
* scale classes.
|
||||
*
|
||||
* Always defined: falls back to `DEFAULT_EMAIL_BRANDING_COLORS` when no tenant
|
||||
* colours are supplied, so the tokens resolve whether or not custom branding is
|
||||
* in play.
|
||||
*/
|
||||
const buildEmailColors = (brandingColors?: EmailBrandingColors): Record<string, string> => {
|
||||
const c = brandingColors ?? DEFAULT_EMAIL_BRANDING_COLORS;
|
||||
|
||||
return {
|
||||
background: c.background,
|
||||
foreground: c.foreground,
|
||||
muted: c.muted,
|
||||
'muted-foreground': c.mutedForeground,
|
||||
primary: c.primary,
|
||||
'primary-foreground': c.primaryForeground,
|
||||
secondary: c.secondary,
|
||||
'secondary-foreground': c.secondaryForeground,
|
||||
accent: c.accent,
|
||||
'accent-foreground': c.accentForeground,
|
||||
destructive: c.destructive,
|
||||
'destructive-foreground': c.destructiveForeground,
|
||||
warning: c.warning,
|
||||
border: c.border,
|
||||
};
|
||||
};
|
||||
|
||||
export const render = async (element: React.ReactNode, options?: RenderOptions) => {
|
||||
const { branding, ...otherOptions } = options ?? {};
|
||||
|
||||
const tailwindColors = buildEmailColors(branding?.brandingColors);
|
||||
|
||||
return ReactEmail.render(
|
||||
<BrandingProvider branding={branding}>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
colors: tailwindColors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -42,6 +87,8 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
|
||||
throw new Error('i18n is required');
|
||||
}
|
||||
|
||||
const tailwindColors = buildEmailColors(branding?.brandingColors);
|
||||
|
||||
return ReactEmail.render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<BrandingProvider branding={branding}>
|
||||
@@ -49,7 +96,7 @@ export const renderWithI18N = async (element: React.ReactNode, options?: RenderO
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors,
|
||||
colors: tailwindColors,
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -27,24 +27,24 @@ export const TemplateAccessAuth2FA = ({
|
||||
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
|
||||
|
||||
<Section className="mt-8">
|
||||
<Heading className="text-center font-semibold text-lg text-slate-900">
|
||||
<Heading className="text-center font-semibold text-foreground text-lg">
|
||||
<Trans>Verification Code Required</Trans>
|
||||
</Heading>
|
||||
|
||||
<Text className="mt-2 text-center text-slate-700">
|
||||
<Text className="mt-2 text-center text-foreground">
|
||||
<Trans>
|
||||
Hi {userName}, you need to enter a verification code to complete the document "{documentTitle}".
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-6 rounded-lg bg-slate-50 p-6 text-center">
|
||||
<Text className="mb-2 font-medium text-slate-600 text-sm">
|
||||
<Section className="mt-6 rounded-lg bg-muted p-6 text-center">
|
||||
<Text className="mb-2 font-medium text-muted-foreground text-sm">
|
||||
<Trans>Your verification code:</Trans>
|
||||
</Text>
|
||||
<Text className="font-bold text-2xl text-slate-900 tracking-wider">{code}</Text>
|
||||
<Text className="font-bold text-2xl text-foreground tracking-wider">{code}</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="mt-4 text-center text-slate-600 text-sm">
|
||||
<Text className="mt-4 text-center text-muted-foreground text-sm">
|
||||
<Plural
|
||||
value={expiresInMinutes}
|
||||
one="This code will expire in # minute."
|
||||
@@ -52,7 +52,7 @@ export const TemplateAccessAuth2FA = ({
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text className="mt-4 text-center text-slate-500 text-sm">
|
||||
<Text className="mt-4 text-center text-muted-foreground text-sm">
|
||||
<Trans>If you didn't request this verification code, you can safely ignore this email.</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@@ -14,26 +14,26 @@ export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: Te
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
<Trans>Welcome to Documenso!</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>An administrator has created a Documenso account for you.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>To get started, please set your password by clicking the button below:</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={resetPasswordLink}
|
||||
>
|
||||
<Trans>Set Password</Trans>
|
||||
</Button>
|
||||
<Text className="mt-8 text-center text-slate-400 text-sm italic">
|
||||
<Text className="mt-8 text-center text-muted-foreground text-sm italic">
|
||||
<Trans>
|
||||
You can also copy and paste this link into your browser: {resetPasswordLink} (link expires in 24 hours)
|
||||
</Trans>
|
||||
@@ -41,10 +41,10 @@ export const TemplateAdminUserCreated = ({ resetPasswordLink, assetBaseUrl }: Te
|
||||
</Section>
|
||||
|
||||
<Section className="mt-8">
|
||||
<Text className="text-center text-slate-400 text-sm">
|
||||
<Text className="text-center text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
If you didn't expect this account or have any questions, please{' '}
|
||||
<Link href="mailto:support@documenso.com" className="text-documenso-500">
|
||||
<Link href="mailto:support@documenso.com" className="text-primary">
|
||||
contact support
|
||||
</Link>
|
||||
.
|
||||
|
||||
@@ -14,22 +14,22 @@ export const TemplateConfirmationEmail = ({ confirmationLink, assetBaseUrl }: Te
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
<Trans>Welcome to Documenso!</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>Before you get started, please confirm your email address by clicking the button below:</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={confirmationLink}
|
||||
>
|
||||
<Trans>Confirm email</Trans>
|
||||
</Button>
|
||||
<Text className="mt-8 text-center text-slate-400 text-sm italic">
|
||||
<Text className="mt-8 text-center text-muted-foreground text-sm italic">
|
||||
<Trans>
|
||||
You can also copy and paste this link into your browser: {confirmationLink} (link expires in 1 hour)
|
||||
</Trans>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const TemplateCustomMessageBody = ({ text }: TemplateCustomMessageBodyPro
|
||||
const paragraphs = normalized.split('\n\n');
|
||||
|
||||
return paragraphs.map((paragraph, i) => (
|
||||
<p key={`p-${i}`} className="whitespace-pre-line break-words font-sans text-base text-slate-400">
|
||||
<p key={`p-${i}`} className="whitespace-pre-line break-words font-sans text-base text-muted-foreground">
|
||||
{paragraph.split('\n').map((line, j) => (
|
||||
<React.Fragment key={`line-${i}-${j}`}>
|
||||
{j > 0 && <br />}
|
||||
|
||||
@@ -22,18 +22,18 @@ export const TemplateDocumentCancel = ({
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
<Trans>
|
||||
{inviterName} has cancelled the document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>All signatures have been voided.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>You don't need to sign it anymore.</Trans>
|
||||
</Text>
|
||||
|
||||
|
||||
@@ -27,24 +27,24 @@ export const TemplateDocumentCompleted = ({
|
||||
<Section>
|
||||
<Section className="mb-4">
|
||||
<Column align="center">
|
||||
<Text className="font-semibold text-[#7AC455] text-base">
|
||||
<Text className="font-semibold text-base text-foreground">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
||||
<Trans>Completed</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
</Section>
|
||||
|
||||
<Text className="mb-0 text-center font-semibold text-lg text-primary">
|
||||
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
|
||||
{customBody || <Trans>“{documentName}” was signed by all signers</Trans>}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>Continue by downloading the document.</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
|
||||
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||
href={downloadLink}
|
||||
>
|
||||
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
||||
|
||||
@@ -40,7 +40,7 @@ export const TemplateDocumentInvite = ({
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
{match({ selfSigner, organisationType, includeSenderDetails, teamName })
|
||||
.with({ selfSigner: true }, () => (
|
||||
<Trans>
|
||||
@@ -75,7 +75,7 @@ export const TemplateDocumentInvite = ({
|
||||
))}
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
|
||||
@@ -87,7 +87,7 @@ export const TemplateDocumentInvite = ({
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sbase no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sbase no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
{match(role)
|
||||
|
||||
@@ -20,18 +20,18 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
|
||||
<Section>
|
||||
<Section className="mb-4">
|
||||
<Column align="center">
|
||||
<Text className="font-semibold text-base text-blue-500">
|
||||
<Text className="font-semibold text-base text-foreground">
|
||||
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
||||
<Trans>Waiting for others</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
</Section>
|
||||
|
||||
<Text className="mb-0 text-center font-semibold text-lg text-primary">
|
||||
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
|
||||
<Trans>“{documentName}” has been signed</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
|
||||
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
|
||||
<Trans>
|
||||
We're still waiting for other signers to sign this document.
|
||||
<br />
|
||||
|
||||
@@ -29,20 +29,20 @@ export const TemplateDocumentRecipientSigned = ({
|
||||
<Section>
|
||||
<Section className="mb-4">
|
||||
<Column align="center">
|
||||
<Text className="font-semibold text-[#7AC455] text-base">
|
||||
<Text className="font-semibold text-base text-foreground">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
||||
<Trans>Completed</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
</Section>
|
||||
|
||||
<Text className="mb-0 text-center font-semibold text-lg text-primary">
|
||||
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
|
||||
<Trans>
|
||||
{recipientReference} has signed "{documentName}"
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
|
||||
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
|
||||
<Trans>{recipientReference} has completed signing the document.</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function TemplateDocumentRejected({
|
||||
}: TemplateDocumentRejectedProps) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Heading className="mb-4 text-center font-semibold text-2xl text-slate-800">
|
||||
<Heading className="mb-4 text-center font-semibold text-2xl text-foreground">
|
||||
<Trans>Document Rejected</Trans>
|
||||
</Heading>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function TemplateDocumentRejected({
|
||||
</Text>
|
||||
|
||||
{rejectionReason && (
|
||||
<Text className="mb-4 text-base text-slate-400">
|
||||
<Text className="mb-4 text-base text-muted-foreground">
|
||||
<Trans>Reason for rejection: {rejectionReason}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
@@ -39,7 +39,7 @@ export function TemplateDocumentRejected({
|
||||
|
||||
<Button
|
||||
href={documentUrl}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
>
|
||||
<Trans>View Document</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function TemplateDocumentRejectionConfirmed({
|
||||
<Trans>Rejection Confirmed</Trans>
|
||||
</Heading>
|
||||
|
||||
<Text className="text-base text-primary">
|
||||
<Text className="text-base text-foreground">
|
||||
<Trans>
|
||||
This email confirms that you have rejected the document{' '}
|
||||
<strong className="font-bold">"{documentName}"</strong> sent by {documentOwnerName}.
|
||||
@@ -30,7 +30,7 @@ export function TemplateDocumentRejectionConfirmed({
|
||||
</Text>
|
||||
|
||||
{reason && (
|
||||
<Text className="font-medium text-base text-slate-400">
|
||||
<Text className="font-medium text-base text-muted-foreground">
|
||||
<Trans>Rejection reason: {reason}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -31,18 +31,18 @@ export const TemplateDocumentReminder = ({
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
<Trans>
|
||||
Reminder: Please {_(actionVerb).toLowerCase()} your document
|
||||
<br />"{documentName}"
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>Hi {recipientName},</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
{match(role)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Continue by signing the document.</Trans>)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>Continue by viewing the document.</Trans>)
|
||||
@@ -54,7 +54,7 @@ export const TemplateDocumentReminder = ({
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={signDocumentLink}
|
||||
>
|
||||
{match(role)
|
||||
|
||||
@@ -25,25 +25,21 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Section>
|
||||
<Column align="center">
|
||||
<Text className="font-semibold text-[#7AC455] text-base">
|
||||
<Text className="font-semibold text-base text-foreground">
|
||||
<Img src={getAssetUrl('/static/completed.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" />
|
||||
<Trans>Completed</Trans>
|
||||
</Text>
|
||||
</Column>
|
||||
</Section>
|
||||
|
||||
<Text className="mt-6 mb-0 text-center font-semibold text-lg text-primary">
|
||||
<Text className="mt-6 mb-0 text-center font-semibold text-foreground text-lg">
|
||||
<Trans>You have signed “{documentName}”</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-slate-400">
|
||||
<Text className="mx-auto mt-1 mb-6 max-w-[80%] text-center text-base text-muted-foreground">
|
||||
<Trans>
|
||||
Create a{' '}
|
||||
<Link
|
||||
href={signUpUrl}
|
||||
target="_blank"
|
||||
className="whitespace-nowrap text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
<Link href={signUpUrl} target="_blank" className="whitespace-nowrap text-primary hover:text-primary">
|
||||
free account
|
||||
</Link>{' '}
|
||||
to access your signed documents at any time.
|
||||
@@ -53,14 +49,14 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
href={signUpUrl}
|
||||
className="mr-4 rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
|
||||
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||
>
|
||||
<Img src={getAssetUrl('/static/user-plus.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
||||
<Trans>Create account</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="rounded-lg border border-slate-200 border-solid px-4 py-2 text-center font-medium text-black text-sm no-underline"
|
||||
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
|
||||
href="https://documenso.com/pricing"
|
||||
>
|
||||
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" />
|
||||
|
||||
@@ -15,26 +15,26 @@ export const TemplateDocumentDelete = ({ reason, documentName, assetBaseUrl }: T
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="mt-6 mb-0 text-left font-semibold text-lg text-primary">
|
||||
<Text className="mt-6 mb-0 text-left font-semibold text-foreground text-lg">
|
||||
<Trans>Your document has been deleted by an admin!</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400">
|
||||
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
|
||||
<Trans>"{documentName}" has been deleted by an admin.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400">
|
||||
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground">
|
||||
<Trans>
|
||||
This document can not be recovered, if you would like to dispute the reason for future documents please
|
||||
contact support.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mt-1 text-left text-base text-slate-400">
|
||||
<Text className="mx-auto mt-1 text-left text-base text-muted-foreground">
|
||||
<Trans>The reason provided for deletion is the following:</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mx-auto mt-1 mb-6 text-left text-base text-slate-400 italic">{reason}</Text>
|
||||
<Text className="mx-auto mt-1 mb-6 text-left text-base text-muted-foreground italic">{reason}</Text>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { Link, Section, Text } from '../components';
|
||||
import { useBranding } from '../providers/branding';
|
||||
@@ -17,10 +18,10 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
|
||||
return (
|
||||
<Section>
|
||||
{reportUrl && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
<Text className="my-4 text-base text-muted-foreground">
|
||||
<Trans>
|
||||
Did not expect this email?{' '}
|
||||
<Link className="text-[#7AC455]" href={reportUrl}>
|
||||
<Link className="text-primary" href={reportUrl}>
|
||||
Click here to report the sender
|
||||
</Link>
|
||||
. Never sign a document you don't recognize or weren't expecting.
|
||||
@@ -29,10 +30,10 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
|
||||
)}
|
||||
|
||||
{isDocument && !branding.brandingHidePoweredBy && (
|
||||
<Text className="my-4 text-base text-slate-400">
|
||||
<Text className="my-4 text-base text-muted-foreground">
|
||||
<Trans>
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
|
||||
<Link className="text-primary" href="https://documen.so/mail-footer">
|
||||
Documenso
|
||||
</Link>
|
||||
.
|
||||
@@ -41,20 +42,20 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
|
||||
)}
|
||||
|
||||
{branding.brandingEnabled && branding.brandingCompanyDetails && (
|
||||
<Text className="my-8 text-slate-400 text-sm">
|
||||
<Text className="my-8 text-muted-foreground text-sm">
|
||||
{branding.brandingCompanyDetails.split('\n').map((line, idx) => {
|
||||
return (
|
||||
<>
|
||||
<Fragment key={idx}>
|
||||
{idx > 0 && <br />}
|
||||
{line}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{branding.brandingEnabled && safeBrandingUrl && (
|
||||
<Text className="my-8 text-slate-400 text-sm">
|
||||
<Text className="my-8 text-muted-foreground text-sm">
|
||||
<Link href={safeBrandingUrl} target="_blank">
|
||||
{safeBrandingUrl}
|
||||
</Link>
|
||||
@@ -62,7 +63,7 @@ export const TemplateFooter = ({ isDocument = true, reportUrl }: TemplateFooterP
|
||||
)}
|
||||
|
||||
{!branding.brandingEnabled && (
|
||||
<Text className="my-8 text-slate-400 text-sm">
|
||||
<Text className="my-8 text-muted-foreground text-sm">
|
||||
Documenso, Inc.
|
||||
<br />
|
||||
2261 Market Street, #5211, San Francisco, CA 94114, USA
|
||||
|
||||
@@ -14,17 +14,17 @@ export const TemplateForgotPassword = ({ resetPasswordLink, assetBaseUrl }: Temp
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>That's okay, it happens! Click the button below to reset your password.</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={resetPasswordLink}
|
||||
>
|
||||
<Trans>Reset Password</Trans>
|
||||
|
||||
@@ -25,13 +25,13 @@ export const TemplateRecipientExpired = ({
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
<Trans>
|
||||
Signing window expired for "{displayName}" on "{documentName}"
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>
|
||||
The signing window for {displayName} on document "{documentName}" has expired. You can resend the document
|
||||
to extend their deadline or cancel the document.
|
||||
@@ -40,7 +40,7 @@ export const TemplateRecipientExpired = ({
|
||||
|
||||
<Section className="my-4 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-sm text-white no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={documentLink}
|
||||
>
|
||||
<Trans>View Document</Trans>
|
||||
|
||||
@@ -18,17 +18,17 @@ export const TemplateResetPassword = ({ assetBaseUrl }: TemplateResetPasswordPro
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section className="flex-row items-center justify-center">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
<Trans>Password updated!</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base text-slate-400">
|
||||
<Text className="my-1 text-center text-base text-muted-foreground">
|
||||
<Trans>Your password has been updated.</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={`${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signin`}
|
||||
>
|
||||
<Trans>Sign In</Trans>
|
||||
|
||||
@@ -32,9 +32,9 @@ export const AccessAuth2FAEmailTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ export const AdminUserCreatedTemplate = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ export const BulkSendCompleteEmail = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(msg`Bulk send operation complete for template "${templateName}"`)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<Text className="text-sm">
|
||||
<Trans>Hi {userName},</Trans>
|
||||
@@ -56,7 +56,7 @@ export const BulkSendCompleteEmail = ({
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{failedCount > 0 && (
|
||||
{errors && errors.length > 0 && (
|
||||
<Section className="mt-4">
|
||||
<Text className="font-semibold text-lg">
|
||||
<Trans>The following errors occurred:</Trans>
|
||||
@@ -64,7 +64,7 @@ export const BulkSendCompleteEmail = ({
|
||||
|
||||
<ul className="my-2 ml-4 list-inside list-disc">
|
||||
{errors.map((error, index) => (
|
||||
<li key={index} className="mt-1 text-destructive text-slate-400 text-sm">
|
||||
<li key={index} className="mt-1 text-destructive text-sm">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -19,9 +19,9 @@ export const ConfirmEmailTemplate = ({
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -33,16 +33,16 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="mail-open.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">
|
||||
<Trans>Verify your team email address</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto mt-6 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
<div className="mx-auto mt-6 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={`${baseUrl}/team/verify/email/${token}`}
|
||||
>
|
||||
<Trans>Accept</Trans>
|
||||
@@ -94,7 +94,7 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-slate-500 text-xs">
|
||||
<Text className="text-center text-muted-foreground text-xs">
|
||||
<Trans>Link expires in 1 hour.</Trans>
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
@@ -25,9 +25,9 @@ export const DocumentCancelTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ export const DocumentCompletedEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -36,27 +36,27 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="mb-0 text-center font-semibold text-lg text-primary">
|
||||
<Text className="mb-0 text-center font-semibold text-foreground text-lg">
|
||||
<Trans>
|
||||
{recipientName} {action} a document by using one of your direct links
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 text-slate-600 text-sm">
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 text-muted-foreground text-sm">
|
||||
{documentName}
|
||||
</div>
|
||||
|
||||
<Section className="my-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={documentLink}
|
||||
>
|
||||
<Trans>View document</Trans>
|
||||
|
||||
@@ -58,9 +58,9 @@ export const DocumentInviteEmailTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
@@ -85,14 +85,14 @@ export const DocumentInviteEmailTemplate = ({
|
||||
<Text className="my-4 font-semibold text-base">
|
||||
<Trans>
|
||||
{inviterName}{' '}
|
||||
<Link className="font-normal text-slate-400" href="mailto:{inviterEmail}">
|
||||
<Link className="font-normal text-muted-foreground" href="mailto:{inviterEmail}">
|
||||
({inviterEmail})
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
<Text className="mt-2 text-base text-muted-foreground">
|
||||
{customBody ? (
|
||||
<TemplateCustomMessageBody text={customBody} />
|
||||
) : (
|
||||
|
||||
@@ -23,8 +23,8 @@ export const DocumentPendingEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ export const DocumentRecipientSignedEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ export function DocumentRejectedEmail({
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ export function DocumentRejectionConfirmedEmail({
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ export const DocumentReminderEmailTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
@@ -58,7 +58,7 @@ export const DocumentReminderEmailTemplate = ({
|
||||
{customBody && (
|
||||
<Container className="mx-auto mt-12 max-w-xl">
|
||||
<Section>
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
<Text className="mt-2 text-base text-muted-foreground">
|
||||
<TemplateCustomMessageBody text={customBody} />
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@@ -23,8 +23,8 @@ export const DocumentSelfSignedEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="p-2">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ export const DocumentSuperDeleteEmailTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ export const ForgotPasswordTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -32,16 +32,16 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<Section className="bg-background">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto h-12 w-12" assetBaseUrl={assetBaseUrl} staticAsset="building-2.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">
|
||||
{type === 'create' ? (
|
||||
<Trans>Account creation request</Trans>
|
||||
) : (
|
||||
@@ -94,7 +94,7 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
|
||||
|
||||
<Section className="mt-8 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={confirmationLink}
|
||||
>
|
||||
<Trans>Review request</Trans>
|
||||
@@ -102,7 +102,7 @@ export const OrganisationAccountLinkConfirmationTemplate = ({
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Text className="text-center text-slate-500 text-xs">
|
||||
<Text className="text-center text-muted-foreground text-xs">
|
||||
<Trans>Link expires in 30 minutes.</Trans>
|
||||
</Text>
|
||||
</Container>
|
||||
|
||||
@@ -37,20 +37,20 @@ export const OrganisationDeleteEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">{_(title)}</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">{_(description)}</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
|
||||
{organisationName}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -32,16 +32,16 @@ export const OrganisationInviteEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="add-user.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">
|
||||
<Trans>Join {organisationName} on Documenso</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -49,25 +49,25 @@ export const OrganisationInviteEmailTemplate = ({
|
||||
<Trans>You have been invited to join the following organisation</Trans>
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
|
||||
{organisationName}
|
||||
</div>
|
||||
|
||||
<Text className="my-1 text-center text-base">
|
||||
<Trans>
|
||||
by <span className="text-slate-900">{senderName}</span>
|
||||
by <span className="text-foreground">{senderName}</span>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Section className="mt-6 mb-6 text-center">
|
||||
<Button
|
||||
className="inline-flex items-center justify-center rounded-lg bg-documenso-500 px-6 py-3 text-center font-medium text-black text-sm no-underline"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-center font-medium text-primary-foreground text-sm no-underline"
|
||||
href={`${baseUrl}/organisation/invite/${token}`}
|
||||
>
|
||||
<Trans>Accept</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-4 inline-flex items-center justify-center rounded-lg bg-gray-50 px-6 py-3 text-center font-medium text-slate-600 text-sm no-underline"
|
||||
className="ml-4 inline-flex items-center justify-center rounded-lg bg-muted px-6 py-3 text-center font-medium text-muted-foreground text-sm no-underline"
|
||||
href={`${baseUrl}/organisation/decline/${token}`}
|
||||
>
|
||||
<Trans>Decline</Trans>
|
||||
|
||||
@@ -34,20 +34,20 @@ export const OrganisationJoinEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="add-user.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">
|
||||
<Trans>A new member has joined your organisation {organisationName}</Trans>
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
|
||||
{memberName || memberEmail}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -34,20 +34,20 @@ export const OrganisationLeaveEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-user.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">
|
||||
<Trans>A member has left your organisation {organisationName}</Trans>
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
|
||||
{memberName || memberEmail}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -32,12 +32,12 @@ export const OrganisationLimitAlertEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">
|
||||
{kind === 'quotaNearing' ? (
|
||||
<Trans>Approaching Your Plan Limits</Trans>
|
||||
) : (
|
||||
@@ -45,7 +45,7 @@ export const OrganisationLimitAlertEmailTemplate = ({
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
|
||||
{organisationName}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ export const RecipientExpiredTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
|
||||
@@ -24,16 +24,16 @@ export const RecipientRemovedFromDocumentTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
|
||||
|
||||
<Section>
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-lg text-primary">
|
||||
<Text className="mx-auto mb-0 max-w-[80%] text-center font-semibold text-foreground text-lg">
|
||||
<Trans>
|
||||
{inviterName} has removed you from the document
|
||||
<br />"{documentName}"
|
||||
|
||||
@@ -24,9 +24,9 @@ export const ResetPasswordTemplate = ({
|
||||
<Head />
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto bg-white font-sans">
|
||||
<Body className="mx-auto my-auto bg-background font-sans">
|
||||
<Section>
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-4 backdrop-blur-sm">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
|
||||
<Section>
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6" />
|
||||
|
||||
@@ -39,19 +39,19 @@ export const ResetPasswordTemplate = ({
|
||||
<Text className="my-4 font-semibold text-base">
|
||||
<Trans>
|
||||
Hi, {userName}{' '}
|
||||
<Link className="font-normal text-slate-400" href={`mailto:${userEmail}`}>
|
||||
<Link className="font-normal text-muted-foreground" href={`mailto:${userEmail}`}>
|
||||
({userEmail})
|
||||
</Link>
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
<Text className="mt-2 text-base text-muted-foreground">
|
||||
<Trans>We've changed your password as you asked. You can now sign in with your new password.</Trans>
|
||||
</Text>
|
||||
<Text className="mt-2 text-base text-slate-400">
|
||||
<Text className="mt-2 text-base text-muted-foreground">
|
||||
<Trans>
|
||||
Didn't request a password change? We are here to help you secure your account, just{' '}
|
||||
<Link className="font-normal text-documenso-700" href="mailto:hi@documenso.com">
|
||||
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
|
||||
contact us
|
||||
</Link>
|
||||
.
|
||||
|
||||
@@ -32,20 +32,20 @@ export const TeamDeleteEmailTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid p-2 backdrop-blur-sm">
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="delete-team.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">{_(title)}</Text>
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">{_(title)}</Text>
|
||||
|
||||
<Text className="my-1 text-center text-base">{_(description)}</Text>
|
||||
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
<div className="mx-auto my-2 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -33,16 +33,16 @@ export const TeamEmailRemovedTemplate = ({
|
||||
<Preview>{_(previewText)}</Preview>
|
||||
|
||||
<Body className="mx-auto my-auto font-sans">
|
||||
<Section className="bg-white text-slate-500">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-slate-200 border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<Section className="bg-background text-muted-foreground">
|
||||
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid px-2 pt-2 backdrop-blur-sm">
|
||||
<TemplateBrandingLogo assetBaseUrl={assetBaseUrl} className="mb-4 h-6 p-2" />
|
||||
|
||||
<Section>
|
||||
<TemplateImage className="mx-auto" assetBaseUrl={assetBaseUrl} staticAsset="mail-open-alert.png" />
|
||||
</Section>
|
||||
|
||||
<Section className="p-2 text-slate-500">
|
||||
<Text className="text-center font-medium text-black text-lg">
|
||||
<Section className="p-2 text-muted-foreground">
|
||||
<Text className="text-center font-medium text-foreground text-lg">
|
||||
<Trans>Team email removed</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -52,7 +52,7 @@ export const TeamEmailRemovedTemplate = ({
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<div className="mx-auto mt-2 mb-6 w-fit rounded-lg bg-gray-50 px-4 py-2 font-medium text-base text-slate-600">
|
||||
<div className="mx-auto mt-2 mb-6 w-fit rounded-lg bg-muted px-4 py-2 font-medium text-base text-muted-foreground">
|
||||
{formatTeamUrl(teamUrl, baseUrl)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"types": ["@documenso/tsconfig/process-env.d.ts"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.d.ts", "**/*.json"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
"exclude": ["dist", "build", "node_modules", "preview"]
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ export const DEFAULT_ENVELOPE_REMINDER_SETTINGS: TEnvelopeReminderSettings = {
|
||||
*/
|
||||
export const MAX_REMINDER_WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Maximum number of automated reminders sent to a recipient before reminders
|
||||
* stop. A manual resend resets the count, re-arming reminders.
|
||||
*/
|
||||
export const MAX_REMINDERS_BEFORE_RESEND = 5;
|
||||
|
||||
const UNIT_TO_LUXON_KEY: Record<TEnvelopeReminderDurationPeriod['unit'], keyof DurationLikeObject> = {
|
||||
day: 'days',
|
||||
week: 'weeks',
|
||||
@@ -53,24 +59,29 @@ export const getEnvelopeReminderDuration = (period: TEnvelopeReminderDurationPer
|
||||
* - `{ sendAfter: { disabled: true }, ... }` means never send the first reminder.
|
||||
* - `{ repeatEvery: { disabled: true }, ... }` means don't repeat after the first reminder.
|
||||
*
|
||||
* A hard cap of `MAX_REMINDER_WINDOW_DAYS` days from `sentAt` is enforced —
|
||||
* any computed reminder beyond that point returns null so reminders stop.
|
||||
* Reminders stop (returns null) once either cap is hit: `MAX_REMINDER_WINDOW_DAYS`
|
||||
* from `sentAt`, or `MAX_REMINDERS_BEFORE_RESEND` reminders already sent.
|
||||
*
|
||||
* `sentAt` is when the signing request was sent to this specific recipient.
|
||||
*
|
||||
* Returns the next Date the reminder should be sent, or null if no reminder should be sent.
|
||||
* Returns the next Date the reminder should be sent, or null if none.
|
||||
*/
|
||||
export const resolveNextReminderAt = (options: {
|
||||
config: TEnvelopeReminderSettings | null;
|
||||
sentAt: Date;
|
||||
lastReminderSentAt: Date | null;
|
||||
reminderCount: number;
|
||||
}): Date | null => {
|
||||
const { config, sentAt, lastReminderSentAt } = options;
|
||||
const { config, sentAt, lastReminderSentAt, reminderCount } = options;
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (reminderCount >= MAX_REMINDERS_BEFORE_RESEND) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxReminderAt = new Date(sentAt.getTime() + Duration.fromObject({ days: MAX_REMINDER_WINDOW_DAYS }).toMillis());
|
||||
|
||||
let candidate: Date;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSignatureFontFamily } from './pdf';
|
||||
|
||||
describe('getSignatureFontFamily', () => {
|
||||
const expectCaveat = (family: string) => expect(family).toBe('Caveat');
|
||||
const expectNotoChain = (family: string) => {
|
||||
expect(family).toContain('"Noto Sans"');
|
||||
expect(family).toContain('"Noto Sans Chinese"');
|
||||
expect(family).toContain('"Noto Sans Japanese"');
|
||||
expect(family).toContain('"Noto Sans Korean"');
|
||||
expect(family).toContain('sans-serif');
|
||||
expect(family).not.toContain('Caveat');
|
||||
};
|
||||
|
||||
it('returns Caveat for ASCII-only text', () => {
|
||||
expectCaveat(getSignatureFontFamily('John Doe'));
|
||||
expectCaveat(getSignatureFontFamily(''));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for any non-ASCII character', () => {
|
||||
expectNotoChain(getSignatureFontFamily('François'));
|
||||
expectNotoChain(getSignatureFontFamily('Müller'));
|
||||
expectNotoChain(getSignatureFontFamily('Søren'));
|
||||
expectNotoChain(getSignatureFontFamily('Иванов'));
|
||||
expectNotoChain(getSignatureFontFamily('Ελληνικά'));
|
||||
expectNotoChain(getSignatureFontFamily('عربي'));
|
||||
expectNotoChain(getSignatureFontFamily('עברית'));
|
||||
expectNotoChain(getSignatureFontFamily('도큐멘소'));
|
||||
expectNotoChain(getSignatureFontFamily('中文签名'));
|
||||
expectNotoChain(getSignatureFontFamily('こんにちは'));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for mixed ASCII + non-ASCII input', () => {
|
||||
expectNotoChain(getSignatureFontFamily('Hello 안녕'));
|
||||
expectNotoChain(getSignatureFontFamily('Ivan Ωmega'));
|
||||
});
|
||||
|
||||
it('returns the Noto chain for scripts not covered by a dedicated Noto file', () => {
|
||||
expectNotoChain(getSignatureFontFamily('ሰላም')); // Ethiopic
|
||||
expectNotoChain(getSignatureFontFamily('សួស្ដី')); // Khmer
|
||||
expectNotoChain(getSignatureFontFamily('ᠮᠣᠩᠭᠣᠯ')); // Mongolian
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,20 @@ export const MIN_HANDWRITING_FONT_SIZE = 20;
|
||||
|
||||
export const CAVEAT_FONT_PATH = () => `${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`;
|
||||
|
||||
const SIGNATURE_FONT_FAMILY_CAVEAT = 'Caveat';
|
||||
|
||||
// CN-before-JP: the JP Noto file's Han glyphs use JP shapes, so pure-CN
|
||||
// text would otherwise render with JP forms. Family names sync with
|
||||
// apps/remix/app/app.css and packages/lib/server-only/pdf/helpers.ts.
|
||||
const SIGNATURE_FONT_FAMILY_NOTO =
|
||||
'"Noto Sans", "Noto Sans Chinese", "Noto Sans Japanese", "Noto Sans Korean", sans-serif';
|
||||
|
||||
const isASCII = (str: string) => /^\p{ASCII}*$/u.test(str);
|
||||
|
||||
// Deliberately never mix handwriting + sans-serif within one signature.
|
||||
export const getSignatureFontFamily = (typedSignatureText: string): string =>
|
||||
isASCII(typedSignatureText) ? SIGNATURE_FONT_FAMILY_CAVEAT : SIGNATURE_FONT_FAMILY_NOTO;
|
||||
|
||||
export const PDF_SIZE_A4_72PPI = {
|
||||
width: 595,
|
||||
height: 842,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user