Merge branch 'main' into feat/public-completed-document-access

This commit is contained in:
ephraimduncan
2026-04-22 18:29:11 +00:00
625 changed files with 48707 additions and 14739 deletions
@@ -0,0 +1,136 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminOrganisationMemberDeleteDialogProps = {
organisationId: string;
organisationName: string;
organisationMemberId: string;
organisationMemberName: string;
organisationMemberEmail: string;
};
export const AdminOrganisationMemberDeleteDialog = ({
organisationId,
organisationName,
organisationMemberId,
organisationMemberName,
organisationMemberEmail,
}: AdminOrganisationMemberDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { mutateAsync: deleteOrganisationMember, isPending } =
trpc.admin.organisationMember.delete.useMutation({
onSuccess: async () => {
toast({
title: _(msg`Success`),
description: _(msg`Member has been removed from the organisation.`),
duration: 5000,
});
setOpen(false);
// Refresh the page to show updated data
await navigate(0);
},
onError: (err) => {
const error = AppError.parseError(err);
console.error(error);
toast({
title: _(msg`An error occurred`),
description: _(msg`We couldn't remove this member. Please try again later.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Remove</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Remove Organisation Member</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
<Trans>
You are about to remove the following user from the organisation{' '}
<span className="font-semibold">{organisationName}</span>:
</Trans>
</DialogDescription>
<Alert className="mt-4" variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
secondaryText={organisationMemberEmail}
/>
</Alert>
</div>
<fieldset disabled={isPending}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () =>
deleteOrganisationMember({
organisationId,
organisationMemberId,
})
}
>
<Trans>Remove member</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,197 @@
import { useEffect, useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminSwapSubscriptionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceOrganisationId: string;
sourceOrganisationName: string;
userId: number;
};
export const AdminSwapSubscriptionDialog = ({
open,
onOpenChange,
sourceOrganisationId,
sourceOrganisationName,
userId,
}: AdminSwapSubscriptionDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [selectedOrgId, setSelectedOrgId] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: orgsData } = trpc.admin.organisation.find.useQuery(
{
ownerUserId: userId,
perPage: 100,
},
{
enabled: open,
},
);
const trpcUtils = trpc.useUtils();
const eligibleOrgs = useMemo(() => {
if (!orgsData?.data) {
return [];
}
return orgsData.data.filter((org) => {
if (org.id === sourceOrganisationId) {
return false;
}
const hasActiveSubscription =
org.subscription &&
(org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE');
return !hasActiveSubscription;
});
}, [orgsData, sourceOrganisationId]);
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
const onSubmit = async () => {
if (!selectedOrgId) {
return;
}
setIsSubmitting(true);
try {
await swapSubscription({
sourceOrganisationId,
targetOrganisationId: selectedOrgId,
});
await trpcUtils.admin.organisation.find.invalidate();
await trpcUtils.admin.organisation.get.invalidate();
onOpenChange(false);
toast({
title: t`Success`,
description: t`Subscription moved successfully`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`Error`,
description: t`Failed to move subscription. Please try again.`,
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
if (!open) {
setSelectedOrgId('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && onOpenChange(value)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Move Subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Move the subscription from "{sourceOrganisationName}" to another organisation owned by
this user.
</Trans>
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">
<Trans>Target Organisation</Trans>
</label>
<Select value={selectedOrgId} onValueChange={setSelectedOrgId}>
<SelectTrigger>
<SelectValue placeholder={t`Select an organisation`} />
</SelectTrigger>
<SelectContent>
{eligibleOrgs.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name} ({org.url})
</SelectItem>
))}
</SelectContent>
</Select>
{eligibleOrgs.length === 0 && orgsData && (
<p className="text-sm text-muted-foreground">
<Trans>No eligible organisations found. The target must be on the free plan.</Trans>
</p>
)}
</div>
{selectedOrg && (
<Alert variant="warning">
<AlertDescription className="mt-0">
<Trans>
This will move the subscription from "{sourceOrganisationName}" to "
{selectedOrg.name}". The source organisation will be reset to the free plan.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={!selectedOrgId}
loading={isSubmitting}
>
<Trans>Move Subscription</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,130 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminTeamMemberDeleteDialogProps = {
teamId: number;
teamName: string;
memberId: string;
memberName: string;
memberEmail: string;
};
export const AdminTeamMemberDeleteDialog = ({
teamId,
teamName,
memberId,
memberName,
memberEmail,
}: AdminTeamMemberDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { mutateAsync: deleteTeamMember, isPending } = trpc.admin.teamMember.delete.useMutation({
onSuccess: async () => {
toast({
title: _(msg`Success`),
description: _(msg`Member has been removed from the team.`),
duration: 5000,
});
setOpen(false);
// Refresh the page to show updated data
await navigate(0);
},
onError: (err) => {
const error = AppError.parseError(err);
console.error(error);
toast({
title: _(msg`An error occurred`),
description: _(msg`We couldn't remove this member. Please try again later.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Remove</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Remove Team Member</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
<Trans>
You are about to remove the following user from the team{' '}
<span className="font-semibold">{teamName}</span>:
</Trans>
</DialogDescription>
<Alert className="mt-4" variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={memberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{memberName}</span>}
secondaryText={memberEmail}
/>
</Alert>
</div>
<fieldset disabled={isPending}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
<Trans>Remove member</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -5,6 +5,7 @@ import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -43,7 +44,7 @@ type ConfirmationDialogProps = {
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
@@ -115,7 +116,7 @@ export function AssistantConfirmationDialog({
<div className="mt-4 flex flex-col gap-4">
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
<p className="text-sm text-muted-foreground">
<Trans>
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
@@ -57,7 +57,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Create Subscription Claim</Trans>
@@ -53,7 +53,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Update Subscription Claim</Trans>
@@ -1,133 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: string;
token?: string;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DocumentDuplicateDialog = ({
id,
token,
open,
onOpenChange,
}: DocumentDuplicateDialogProps) => {
const navigate = useNavigate();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam();
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery(
{
envelopeId: id,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpcReact.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
await navigate(`${documentsPath}/${id}/edit`);
onOpenChange(false);
},
});
const onDuplicate = async () => {
try {
await duplicateEnvelope({ envelopeId: id });
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be duplicated at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Duplicate</Trans>
</DialogTitle>
</DialogHeader>
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
<Trans>Loading Document...</Trans>
</h1>
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
version="original"
/>
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={isDuplicating}
loading={isDuplicating}
onClick={onDuplicate}
className="flex-1"
>
<Trans>Duplicate</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -4,13 +4,14 @@ 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 Recipient, SigningStatus, type Team, type User } from '@prisma/client';
import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
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';
@@ -45,10 +46,10 @@ const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: Recipient[];
recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
@@ -183,7 +184,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={isSubmitting}
>
@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
@@ -12,61 +12,76 @@ import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
type DocumentDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
type EnvelopeDeleteDialogProps = {
id: string;
type: EnvelopeType;
trigger?: React.ReactNode;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
documentTitle: string;
title: string;
canManageDocument: boolean;
};
export const DocumentDeleteDialog = ({
export const EnvelopeDeleteDialog = ({
id,
open,
onOpenChange,
type,
trigger,
onDelete,
status,
documentTitle,
title,
canManageDocument,
}: DocumentDeleteDialogProps) => {
}: EnvelopeDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const { t } = useLingui();
const deleteMessage = msg`delete`;
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
const isDocument = type === EnvelopeType.DOCUMENT;
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: _(msg`Document deleted`),
description: _(msg`"${documentTitle}" has been successfully deleted`),
title: canManageDocument
? isDocument
? t`Document deleted`
: t`Template deleted`
: isDocument
? t`Document hidden`
: t`Template hidden`,
description: canManageDocument
? t`"${title}" has been successfully deleted`
: t`"${title}" has been successfully hidden`,
duration: 5000,
});
await onDelete?.();
onOpenChange(false);
setOpen(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
title: t`Something went wrong`,
description: isDocument
? t`This document could not be deleted at this time. Please try again.`
: t`This template could not be deleted at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
@@ -82,11 +97,13 @@ export const DocumentDeleteDialog = ({
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
setIsDeleteEnabled(event.target.value === t(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -96,11 +113,11 @@ export const DocumentDeleteDialog = ({
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{documentTitle}"</strong>
You are about to delete <strong>"{title}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{documentTitle}"</strong>
You are about to hide <strong>"{title}"</strong>
</Trans>
)}
</DialogDescription>
@@ -111,10 +128,17 @@ export const DocumentDeleteDialog = ({
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
{type === EnvelopeType.DOCUMENT ? (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
) : (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this template will be permanently deleted.
</Trans>
)}
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
@@ -176,23 +200,25 @@ export const DocumentDeleteDialog = ({
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
/>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void deleteDocument({ documentId: id })}
onClick={() => void deleteEnvelope({ envelopeId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
</Button>
</DialogFooter>
</DialogContent>
@@ -13,8 +13,10 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
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';
@@ -61,10 +63,7 @@ export type EnvelopeDistributeDialogProps = {
export const ZEnvelopeDistributeFormSchema = z.object({
meta: z.object({
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
subject: z.string(),
message: z.string(),
distributionMethod: z
@@ -116,10 +115,15 @@ export const EnvelopeDistributeDialog = ({
} = form;
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
organisationId: organisation.id,
perPage: 100,
});
trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const emails = emailData?.data || [];
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useNavigate } from 'react-router';
@@ -10,6 +9,7 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -41,19 +41,20 @@ export const EnvelopeDuplicateDialog = ({
const team = useCurrentTeam();
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: t`Envelope Duplicated`,
description: t`Your envelope has been successfully duplicated.`,
title: isDocument ? t`Document Duplicated` : t`Template Duplicated`,
description: isDocument
? t`Your document has been successfully duplicated.`
: t`Your template has been successfully duplicated.`,
duration: 5000,
});
const path =
envelopeType === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const path = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
await navigate(`${path}/${id}/edit`);
setOpen(false);
@@ -66,7 +67,9 @@ export const EnvelopeDuplicateDialog = ({
} catch {
toast({
title: t`Something went wrong`,
description: t`This document could not be duplicated at this time. Please try again.`,
description: isDocument
? t`This document could not be duplicated at this time. Please try again.`
: t`This template could not be duplicated at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
@@ -78,30 +81,25 @@ export const EnvelopeDuplicateDialog = ({
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
{envelopeType === EnvelopeType.DOCUMENT ? (
<DialogHeader>
<DialogTitle>
<Trans>Duplicate Document</Trans>
</DialogTitle>
<DialogDescription>
<DialogHeader>
<DialogTitle>
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Trans>This document will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
) : (
<DialogHeader>
<DialogTitle>
<Trans>Duplicate Template</Trans>
</DialogTitle>
<DialogDescription>
) : (
<Trans>This template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
)}
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isDuplicating}>
<Trans>Cancel</Trans>
</Button>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isDuplicating}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
<Trans>Duplicate</Trans>
@@ -0,0 +1,368 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { AlertTriangleIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { trpc } from '@documenso/trpc/react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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';
const ZEditEnvelopeItemFormSchema = z.object({
title: ZDocumentTitleSchema,
});
type TEditEnvelopeItemFormSchema = z.infer<typeof ZEditEnvelopeItemFormSchema>;
/**
* Note: This should only be visible if the envelope item is editable.
*/
export type EnvelopeItemEditDialogProps = {
envelopeItem: { id: string; title: string };
allowConfigureTitle: boolean;
trigger: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopeItemEditDialog = ({
envelopeItem,
allowConfigureTitle,
trigger,
...props
}: EnvelopeItemEditDialogProps) => {
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope, editorFields, setLocalEnvelope, isEmbedded } = useCurrentEnvelopeEditor();
const [isOpen, setIsOpen] = useState(false);
const [replacementFile, setReplacementFile] = useState<{ file: File; pageCount: number } | null>(
null,
);
const [isDropping, setIsDropping] = useState(false);
const form = useForm<TEditEnvelopeItemFormSchema>({
resolver: zodResolver(ZEditEnvelopeItemFormSchema),
defaultValues: {
title: envelopeItem.title,
},
});
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === data.id
? { ...item, documentDataId: data.documentDataId, title: data.title }
: item,
),
});
if (fields) {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
},
});
const fieldsOnExcessPages =
replacementFile !== null
? envelope.fields.filter(
(field) =>
field.envelopeItemId === envelopeItem.id && field.page > replacementFile.pageCount,
)
: [];
const onFileDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: t`Upload failed`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
};
const onFileDrop = async (files: File[]) => {
const file = files[0];
if (!file || isDropping) {
return;
}
setIsDropping(true);
try {
const arrayBuffer = await file.arrayBuffer();
const fileData = new Uint8Array(arrayBuffer.slice(0));
const { PDF } = await import('@libpdf/core');
const pdfDoc = await PDF.load(fileData);
setReplacementFile({
file,
pageCount: pdfDoc.getPageCount(),
});
} catch (err) {
console.error(err);
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
variant: 'destructive',
});
}
setIsDropping(false);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: { 'application/pdf': ['.pdf'] },
maxFiles: 1,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
disabled: form.formState.isSubmitting,
onDrop: (files) => void onFileDrop(files),
onDropRejected: onFileDropRejected,
});
const onSubmit = async (data: TEditEnvelopeItemFormSchema) => {
if (isDropping || !replacementFile) {
return;
}
try {
const { file, pageCount } = replacementFile;
if (isEmbedded) {
const arrayBuffer = await file.arrayBuffer();
const fileData = new Uint8Array(arrayBuffer.slice(0));
const remainingFields = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItem.id || field.page <= pageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === envelopeItem.id ? { ...item, title: data.title, data: fileData } : item,
),
fields: remainingFields,
});
editorFields.resetForm(remainingFields);
} else {
const payload = {
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
title: data.title,
} satisfies TReplaceEnvelopeItemPdfPayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
await replaceEnvelopeItemPdf(formData);
}
setIsOpen(false);
} catch {
toast({
title: t`Failed to update item`,
description: t`Something went wrong while updating the envelope item.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset({ title: envelopeItem.title });
setReplacementFile(null);
setIsDropping(false);
}
}, [isOpen, form, envelopeItem.title]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Dialog
{...props}
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Edit Item</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Update the title or replace the PDF file.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document Title</Trans>
</FormLabel>
<FormControl>
<Input
data-testid="envelope-item-edit-title-input"
placeholder={t`Document Title`}
disabled={!allowConfigureTitle}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Replace PDF</Trans>
</FormLabel>
{replacementFile ? (
<div className="mt-1.5 space-y-2">
<div
data-testid="envelope-item-edit-selected-file"
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
>
<div className="flex min-w-0 items-center space-x-2">
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{replacementFile.file.name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(replacementFile.file.size)}
{isDropping ? ' · …' : ' · '}
{!isDropping && replacementFile.pageCount !== null && (
<Plural
one="1 page"
other="# pages"
value={replacementFile.pageCount}
/>
)}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="envelope-item-edit-clear-file"
onClick={() => {
setReplacementFile(null);
}}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{fieldsOnExcessPages.length > 0 && (
<Alert variant="warning" padding="tight">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription data-testid="envelope-item-edit-field-warning">
<Plural
one="1 field will be deleted because the new PDF has fewer pages than the current one."
other="# fields will be deleted because the new PDF has fewer pages than the current one."
value={fieldsOnExcessPages.length}
/>
</AlertDescription>
</Alert>
)}
</div>
) : (
<div
data-testid="envelope-item-edit-dropzone"
{...getRootProps()}
className={cn(
'mt-1.5 flex cursor-pointer items-center justify-center rounded-md border border-dashed border-border px-4 py-4 transition-colors',
isDragActive
? 'border-primary/50 bg-primary/5'
: 'hover:border-muted-foreground/50 hover:bg-muted/50',
)}
>
<input {...getInputProps()} />
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<UploadIcon className="h-4 w-4" />
<span>
<Trans>Drop PDF here or click to select</Trans>
</span>
</div>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isDropping || !replacementFile}
data-testid="envelope-item-edit-update-button"
>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -4,12 +4,13 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -38,7 +39,7 @@ import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[];
recipients: TEnvelopeRecipientLite[];
};
trigger?: React.ReactNode;
};
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type EnvelopeRenameDialogProps = {
id: string;
initialTitle: string;
open: boolean;
onOpenChange: (_open: boolean) => void;
onSuccess?: () => Promise<void>;
envelopeType?: 'document' | 'template';
};
export const EnvelopeRenameDialog = ({
id,
initialTitle,
open,
onOpenChange,
onSuccess,
envelopeType = 'document',
}: EnvelopeRenameDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const [title, setTitle] = useState(initialTitle);
useEffect(() => {
if (open) {
setTitle(initialTitle);
}
}, [open, initialTitle]);
const isTemplate = envelopeType === 'template';
const { mutate: updateEnvelope, isPending } = trpcReact.envelope.update.useMutation({
onSuccess: async () => {
await onSuccess?.();
toast({
title: isTemplate ? t`Template Renamed` : t`Document Renamed`,
description: isTemplate
? t`Your template has been successfully renamed.`
: t`Your document has been successfully renamed.`,
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
description: t`Something went wrong. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
const trimmedTitle = title.trim();
const onRename = () => {
if (!trimmedTitle || trimmedTitle === initialTitle) {
onOpenChange(false);
return;
}
updateEnvelope({
envelopeId: id,
data: {
title: trimmedTitle,
},
});
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isTemplate ? <Trans>Rename Template</Trans> : <Trans>Rename Document</Trans>}
</DialogTitle>
</DialogHeader>
<div className="py-2">
<Label htmlFor="title" className="sr-only">
<Trans>Title</Trans>
</Label>
<Input
id="title"
value={title}
placeholder={t`Enter a new title`}
onChange={(e) => setTitle(e.target.value)}
disabled={isPending}
maxLength={DOCUMENT_TITLE_MAX_LENGTH}
className="w-full"
autoFocus
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={isPending || !trimmedTitle || trimmedTitle === initialTitle}
loading={isPending}
onClick={() => void onRename()}
>
<Trans>Rename</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,181 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { 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,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type EnvelopeSaveAsTemplateDialogProps = {
envelopeId: string;
trigger?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const EnvelopeSaveAsTemplateDialog = ({
envelopeId,
trigger,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: EnvelopeSaveAsTemplateDialogProps) => {
const navigate = useNavigate();
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const { toast } = useToast();
const { t } = useLingui();
const team = useCurrentTeam();
const templatesPath = formatTemplatesPath(team.url);
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: t`Template Created`,
description: t`Your document has been saved as a template.`,
duration: 5000,
});
await navigate(`${templatesPath}/${id}/edit`);
setOpen(false);
},
});
const onSubmit = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await saveAsTemplate({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
} catch {
toast({
title: t`Something went wrong`,
description: t`This document could not be saved as a template at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (isPending) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Save as Template</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create a template from this document.</Trans>
</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="envelopeIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label
htmlFor="envelopeIncludeFields"
className={!includeRecipients ? 'opacity-50' : ''}
>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" loading={isPending} onClick={onSubmit}>
<Trans>Save as Template</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -40,13 +40,21 @@ type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type FolderCreateDialogProps = {
type: FolderType;
trigger?: React.ReactNode;
parentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
export const FolderCreateDialog = ({
type,
trigger,
parentFolderId,
...props
}: FolderCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const parentId = parentFolderId ?? folderId;
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
@@ -62,7 +70,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
try {
await createFolder({
name: data.name,
parentId: folderId,
parentId,
type,
});
@@ -17,6 +17,7 @@ import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '@documenso/lib/constants/app'
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
import { cn } from '@documenso/ui/lib/utils';
@@ -94,7 +95,7 @@ type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportOrganisationMemberSchema = z.array(
z.object({
email: z.string().email(),
email: zEmail(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
}),
);
@@ -329,12 +330,12 @@ export const OrganisationMemberInviteDialog = ({
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<TabsTrigger value="INDIVIDUAL" className="w-full hover:text-foreground">
<MailIcon size={20} className="mr-2" />
<Trans>Invite Members</Trans>
</TabsTrigger>
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
<TabsTrigger value="BULK" className="w-full hover:text-foreground">
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
</TabsTrigger>
</TabsList>
@@ -382,7 +383,7 @@ export const OrganisationMemberInviteDialog = ({
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectTrigger className="max-w-[200px] text-muted-foreground">
<SelectValue />
</SelectTrigger>
@@ -447,7 +448,7 @@ export const OrganisationMemberInviteDialog = ({
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
@@ -5,6 +5,7 @@ import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -24,10 +25,7 @@ import {
import { Input } from '@documenso/ui/primitives/input';
const ZSignFieldEmailFormSchema = z.object({
email: z
.string()
.email()
.min(1, { message: msg`Email is required`.id }),
email: zEmail().min(1, { message: msg`Email is required`.id }),
});
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
@@ -10,8 +10,10 @@ import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -73,8 +75,14 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
const { toast } = useToast();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const utils = trpc.useUtils();
const canInviteOrganisationMembers = canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
organisation.currentOrganisationRole,
);
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
defaultValues: {
@@ -106,7 +114,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
if (hasNoAvailableMembers) {
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
setInviteDialogOpen(true);
return;
}
@@ -231,7 +239,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
onKeyDown={(e) => {
if (e.key === 'Enter' && form.getValues('members').length === 0) {
e.preventDefault();
if (hasNoAvailableMembers) {
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
setInviteDialogOpen(true);
}
// Don't show toast - the disabled Next button already communicates this
@@ -260,21 +268,32 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<Trans>No organisation members available</Trans>
</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
<Trans>
To add members to this team, you must first add them to the
organisation.
</Trans>
{canInviteOrganisationMembers ? (
<Trans>
To add members to this team, you must first add them to the
organisation.
</Trans>
) : (
<Trans>
To add members to this team, they must first be invited to the
organisation. Only organisation admins and managers can invite
new members please contact one of them to invite members on
your behalf.
</Trans>
)}
</p>
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button type="button" variant="default">
<UserPlusIcon className="mr-2 h-4 w-4" />
<Trans>Invite organisation members</Trans>
</Button>
}
/>
{canInviteOrganisationMembers && (
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button type="button" variant="default">
<UserPlusIcon className="mr-2 h-4 w-4" />
<Trans>Invite organisation members</Trans>
</Button>
}
/>
)}
</div>
) : (
<MultiSelectCombobox
@@ -310,30 +329,32 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<Trans>Select members to add to this team</Trans>
</FormDescription>
<Alert
variant="neutral"
className="mt-2 flex items-center gap-2 space-y-0"
>
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
{canInviteOrganisationMembers && (
<Alert
variant="neutral"
className="mt-2 flex items-center gap-2 space-y-0"
>
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
)}
</>
)}
</FormItem>
@@ -1,93 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onDelete?: () => Promise<void> | void;
};
export const TemplateDeleteDialog = ({
id,
open,
onOpenChange,
onDelete,
}: TemplateDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: async () => {
await onDelete?.();
toast({
title: _(msg`Template deleted`),
description: _(msg`Your template has been successfully deleted.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This template could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to delete this template?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={isPending}
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant="destructive"
loading={isPending}
onClick={async () => deleteTemplate({ templateId: id })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
import { RecipientRole, type TemplateDirectLink } from '@prisma/client';
import {
CircleDotIcon,
CircleIcon,
@@ -21,6 +21,7 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
@@ -52,7 +53,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkDialogProps = {
templateId: number;
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
recipients: TRecipientLite[];
trigger?: React.ReactNode;
onCreateSuccess?: () => Promise<void> | void;
onDeleteSuccess?: () => Promise<void> | void;
@@ -1,89 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDuplicateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateDuplicateDialog = ({
id,
open,
onOpenChange,
}: TemplateDuplicateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isPending } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
toast({
title: _(msg`Template duplicated`),
description: _(msg`Your template has been duplicated successfully.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while duplicating template.`),
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to duplicate this template?</Trans>
</DialogTitle>
<DialogDescription className="pt-2">
<Trans>Your template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
disabled={isPending}
variant="secondary"
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={async () =>
duplicateTemplate({
templateId: id,
})
}
>
<Trans>Duplicate</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -4,11 +4,11 @@ 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 { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
@@ -20,8 +20,8 @@ import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -48,7 +48,6 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZAddRecipientsForNewDocumentSchema = z.object({
@@ -79,7 +78,7 @@ export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string;
trigger?: React.ReactNode;
@@ -202,19 +201,32 @@ export function TemplateUseDialog({
} catch (err) {
const error = AppError.parseError(err);
const toastPayload: Toast = {
const errorMessage = match(error.code)
.with(
'DOCUMENT_SEND_FAILED',
() => msg`The document was created but could not be sent to recipients.`,
)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
() =>
msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
)
.with(
AppErrorCode.NOT_FOUND,
() => msg`The template or one of its recipients could not be found.`,
)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this plan.`,
)
.otherwise(() => msg`An error occurred while creating document from template.`);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while creating document from template.`),
description: _(errorMessage),
variant: 'destructive',
};
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = _(
msg`The document was created but could not be sent to recipients.`,
);
}
toast(toastPayload);
});
}
};