Compare commits

..

4 Commits

Author SHA1 Message Date
Ephraim Duncan 49b792503f fix: query envelope table for openpage stats (#2086) 2025-10-21 12:43:57 +00:00
Catalin Pit c3dc76b1b4 feat: add API support for folders (#1967) 2025-10-21 18:22:19 +11:00
David Nguyen daab8461c7 fix: email attachment names (#2085) 2025-10-21 12:59:40 +11:00
Lucas Smith 1ffc4bd703 v1.13.0 2025-10-21 11:21:04 +11:00
72 changed files with 636 additions and 2366 deletions
@@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,
@@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
const onFormSubmit = async () => {
try {
await deleteFolder({
id: folder.id,
folderId: folder.id,
});
onOpenChange(false);
@@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
@@ -63,12 +63,16 @@ export const FolderMoveDialog = ({
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) return;
if (!folder) {
return;
}
try {
await moveFolder({
id: folder.id,
parentId: targetFolderId || null,
folderId: folder.id,
data: {
parentId: targetFolderId || null,
},
});
onOpenChange(false);
@@ -61,8 +61,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
const { toast } = useToast();
const { mutateAsync: updateFolder } = trpc.folder.updateFolder.useMutation();
const isTeamContext = !!team;
const form = useForm<z.infer<typeof ZUpdateFolderFormSchema>>({
resolver: zodResolver(ZUpdateFolderFormSchema),
defaultValues: {
@@ -87,11 +85,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
try {
await updateFolder({
id: folder.id,
name: data.name,
visibility: isTeamContext
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
folderId: folder.id,
data: {
name: data.name,
visibility: data.visibility,
},
});
toast({
@@ -140,38 +138,36 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
)}
/>
{isTeamContext && (
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Visibility</Trans>
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Select visibility`} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>
<Trans>Everyone</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<Trans>Managers and above</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>
<Trans>Admins only</Trans>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="visibility"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Visibility</Trans>
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t`Select visibility`} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={DocumentVisibility.EVERYONE}>
<Trans>Everyone</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE}>
<Trans>Managers and above</Trans>
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN}>
<Trans>Admins only</Trans>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
@@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,
@@ -223,8 +223,6 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@@ -249,8 +247,6 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@@ -480,17 +476,6 @@ export const DocumentEditForm = ({
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
expiryAmount={document.documentMeta?.expiryAmount}
expiryUnit={
document.documentMeta?.expiryUnit as
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| null
| undefined
}
fields={fields}
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
@@ -156,14 +156,6 @@ export const DocumentPageViewRecipients = ({
</PopoverHover>
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.EXPIRED && (
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (
@@ -12,6 +12,7 @@ import {
import { Link } from 'react-router';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { type TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
@@ -28,22 +29,15 @@ import { useCurrentTeam } from '~/providers/team';
export type FolderCardProps = {
folder: TFolderWithSubfolders;
onMove: (folder: TFolderWithSubfolders) => void;
onPin: (folderId: string) => void;
onUnpin: (folderId: string) => void;
onSettings: (folder: TFolderWithSubfolders) => void;
onDelete: (folder: TFolderWithSubfolders) => void;
};
export const FolderCard = ({
folder,
onMove,
onPin,
onUnpin,
onSettings,
onDelete,
}: FolderCardProps) => {
export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardProps) => {
const team = useCurrentTeam();
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
const formatPath = () => {
const rootPath =
folder.type === FolderType.DOCUMENT
@@ -53,6 +47,15 @@ export const FolderCard = ({
return `${rootPath}/f/${folder.id}`;
};
const updateFolder = async ({ pinned }: { pinned: boolean }) => {
await updateFolderMutation({
folderId: folder.id,
data: {
pinned,
},
});
};
return (
<Link to={formatPath()} data-folder-id={folder.id} data-folder-name={folder.name}>
<Card className="hover:bg-muted/50 border-border h-full border transition-all">
@@ -112,9 +115,7 @@ export const FolderCard = ({
<Trans>Move</Trans>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
>
<DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
<PinIcon className="mr-2 h-4 w-4" />
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
</DropdownMenuItem>
@@ -34,9 +34,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const { data: foldersData, isPending } = trpc.folder.getFolders.useQuery({
type,
parentId,
@@ -155,8 +152,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -180,8 +175,6 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -41,9 +41,6 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
case RecipientStatusType.REJECTED:
classes = 'bg-red-200 text-red-800';
break;
case RecipientStatusType.EXPIRED:
classes = 'bg-orange-200 text-orange-800';
break;
default:
break;
}
@@ -48,20 +48,13 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const expiredRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) =>
getRecipientType(recipient) !== RecipientStatusType.REJECTED &&
getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...expiredRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
@@ -124,30 +117,6 @@ export const StackAvatarsWithTooltip = ({
</div>
)}
{expiredRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Expired</Trans>
</h1>
{expiredRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
@@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@@ -36,7 +36,6 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
@@ -88,15 +87,8 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isPending,
isComplete,
isSigned,
isExpired,
isCurrentTeamDocument,
})
.with({ isRecipient: true, isExpired: true }, () => (
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
<Clock className="-ml-1 mr-2 h-4 w-4" />
<Trans>Expired</Trans>
</Button>
))
.with(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (
@@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
@@ -193,7 +193,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
const isPending = row.status === DocumentStatusEnum.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
if (!recipient) {
@@ -231,14 +230,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
isSigned,
isExpired,
})
.with({ isExpired: true }, () => (
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
<Clock className="-ml-1 mr-2 h-4 w-4" />
<Trans>Expired</Trans>
</Button>
))
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>
@@ -42,9 +42,6 @@ export default function DocumentsFoldersPage() {
parentId: null,
});
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const navigateToFolder = (folderId?: string | null) => {
const documentsPath = formatDocumentsPath(team.url);
@@ -113,8 +110,6 @@ export default function DocumentsFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -147,8 +142,6 @@ export default function DocumentsFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -42,9 +42,6 @@ export default function TemplatesFoldersPage() {
parentId: null,
});
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
const navigateToFolder = (folderId?: string | null) => {
const templatesPath = formatTemplatesPath(team.url);
@@ -113,8 +110,6 @@ export default function TemplatesFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -147,8 +142,6 @@ export default function TemplatesFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@@ -16,7 +16,6 @@ import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envel
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { expireRecipient } from '@documenso/lib/server-only/recipient/expire-recipient';
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@@ -26,7 +25,6 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@@ -138,13 +136,6 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
const { documentMeta } = document;
if (isRecipientExpired(recipient)) {
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
if (expiredRecipient) {
throw redirect(`/sign/${token}/expired`);
}
}
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw redirect(`/sign/${token}/rejected`);
}
@@ -248,13 +239,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
if (isRecipientExpired(recipient)) {
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
if (expiredRecipient) {
throw redirect(`/sign/${token}/expired`);
}
}
if (isRejected) {
throw redirect(`/sign/${token}/rejected`);
}
@@ -1,141 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/expired';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document) {
throw new Response('Not Found', { status: 404 });
}
const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
if (!recipient) {
throw new Response('Not Found', { status: 404 });
}
if (!isRecipientExpired(recipient)) {
throw new Response('Not Found', { status: 404 });
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
const recipientReference =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
if (isDocumentAccessValid) {
return {
isDocumentAccessValid: true,
recipientReference,
truncatedTitle,
recipient,
};
}
// Don't leak data if access is denied.
return {
isDocumentAccessValid: false,
recipientReference,
};
}
export default function SigningExpiredPage({ loaderData }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { isDocumentAccessValid, recipientReference, truncatedTitle, recipient } = loaderData;
if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientReference} />;
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<Clock8 className="h-10 w-10 text-orange-500" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing Link Expired</Trans>
</h2>
</div>
<div className="mt-4 flex items-center text-center text-sm text-orange-600">
<Trans>This signing link is no longer valid</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
The signing link has expired and can no longer be used to sign the document. Please
contact the document sender if you need a new signing link.
</Trans>
</p>
{recipient?.expired && (
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>
Expired on:{' '}
{new Date(recipient.expired).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Trans>
</p>
)}
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>Return Home</Link>
</Button>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -103,5 +103,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.12.10"
"version": "1.13.0"
}
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.12.10",
"version": "1.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.12.10",
"version": "1.13.0",
"workspaces": [
"apps/*",
"packages/*"
@@ -89,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.12.10",
"version": "1.13.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.3.2",
"@documenso/api": "*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.12.10",
"version": "1.13.0",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@@ -24,6 +24,7 @@ import {
seedDraftDocument,
seedPendingDocument,
} from '@documenso/prisma/seed/documents';
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -326,11 +327,6 @@ test.describe('Document API V2', () => {
data: { documentId: mapSecondaryIdToDocumentId(doc.secondaryId) },
});
const asdf = await res.json();
console.log({
asdf,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
@@ -407,11 +403,6 @@ test.describe('Document API V2', () => {
headers: { Authorization: `Bearer ${tokenA}` },
});
const asdf = await res.json();
console.log({
asdf,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
@@ -2715,4 +2706,154 @@ test.describe('Document API V2', () => {
expect(res.status()).toBe(200);
});
});
test.describe('Folder list endpoint', () => {
test('should block unauthorized access to folder list endpoint', async ({ request }) => {
await seedBlankFolder(userA, teamA.id);
await seedBlankFolder(userA, teamA.id);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const { data } = await res.json();
expect(data.every((folder: { userId: number }) => folder.userId !== userA.id)).toBe(true);
expect(data.length).toBe(0);
});
test('should allow authorized access to folder list endpoint', async ({ request }) => {
await seedBlankFolder(userA, teamA.id);
await seedBlankFolder(userA, teamA.id);
// Other team folders should not be visible.
await seedBlankFolder(userA, teamB.id);
await seedBlankFolder(userA, teamB.id);
// Other team and user folders should not be visible.
await seedBlankFolder(userB, teamB.id);
await seedBlankFolder(userB, teamB.id);
const res = await request.get(`${WEBAPP_BASE_URL}/api/v2-beta/folder`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const { data } = await res.json();
expect(data.length).toBe(2);
expect(data.every((folder: { userId: number }) => folder.userId === userA.id)).toBe(true);
});
});
test.describe('Folder create endpoint', () => {
test('should block unauthorized access to folder create endpoint', async ({ request }) => {
const unauthorizedFolder = await seedBlankFolder(userB, teamB.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
parentId: unauthorizedFolder.id,
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder create endpoint', async ({ request }) => {
const authorizedFolder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
parentId: authorizedFolder.id,
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const noParentRes = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
name: 'Test Folder',
type: 'DOCUMENT',
},
});
expect(noParentRes.ok()).toBeTruthy();
expect(noParentRes.status()).toBe(200);
});
});
test.describe('Folder update endpoint', () => {
test('should block unauthorized access to folder update endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
folderId: folder.id,
data: {
name: 'Updated Folder Name',
},
},
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder update endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/update`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
folderId: folder.id,
data: {
name: 'Updated Folder Name',
},
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
test.describe('Folder delete endpoint', () => {
test('should block unauthorized access to folder delete endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: { folderId: folder.id },
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to folder delete endpoint', async ({ request }) => {
const folder = await seedBlankFolder(userA, teamA.id);
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/folder/delete`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: { folderId: folder.id },
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
});
});
});
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { useEffect, useState } from 'react';
import { RefObject, useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.
@@ -13,7 +13,6 @@ export enum RecipientStatusType {
WAITING = 'waiting',
UNSIGNED = 'unsigned',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export const getRecipientType = (
@@ -28,10 +27,6 @@ export const getRecipientType = (
return RecipientStatusType.REJECTED;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return RecipientStatusType.EXPIRED;
}
if (
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
@@ -57,10 +52,6 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
return RecipientStatusType.UNSIGNED;
}
if (types.includes(RecipientStatusType.EXPIRED)) {
return RecipientStatusType.EXPIRED;
}
if (types.includes(RecipientStatusType.OPENED)) {
return RecipientStatusType.OPENED;
}
@@ -15,7 +15,6 @@ export const getRecipientsStats = async () => {
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SigningStatus.EXPIRED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};
@@ -10,7 +10,6 @@ import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { calculateRecipientExpiry } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import type { SupportedLanguageCodes } from '../../constants/i18n';
@@ -38,8 +37,6 @@ export type CreateDocumentMetaOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
expiryAmount?: number;
expiryUnit?: string;
requestMetadata: ApiRequestMetadata;
};
@@ -62,8 +59,6 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
@@ -125,30 +120,9 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
},
});
if (expiryAmount !== undefined || expiryUnit !== undefined) {
const newExpiryDate = calculateRecipientExpiry(
upsertedDocumentMeta.expiryAmount,
upsertedDocumentMeta.expiryUnit,
new Date(),
);
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
signingStatus: { not: 'SIGNED' },
role: { not: 'CC' },
},
data: {
expired: newExpiryDate,
},
});
}
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
// Create audit logs only for document type envelopes.
@@ -1,292 +0,0 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import {
DocumentSource,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
userId: number;
teamId: number;
documentDataId: string;
normalizePdf?: boolean;
data: {
title: string;
externalId?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentTemporaryRequest['recipients'];
folderId?: string;
expiryAmount?: number;
expiryUnit?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
};
export const createDocumentV2 = async ({
userId,
teamId,
documentDataId,
normalizePdf,
data,
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
organisation: {
select: {
organisationClaim: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
});
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFileServerSide({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
});
const recipientsHaveActionAuth = data.recipients?.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
!team.organisation.organisationClaim.flags.cfr21
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const { teamRole } = await getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
});
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
const emailId = meta?.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId: data.externalId,
documentDataId,
userId,
teamId,
authOptions,
visibility,
folderId,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
...meta,
expiryAmount: data.expiryAmount,
expiryUnit: data.expiryUnit,
}),
},
},
});
await Promise.all(
(data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const expiryDate = calculateRecipientExpiry(
data.expiryAmount ?? null,
data.expiryUnit ?? null,
new Date(), // Calculate from current time
);
await tx.recipient.create({
data: {
documentId: document.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions: recipientAuthOptions,
expired: expiryDate,
fields: {
createMany: {
data: (recipient.fields || []).map((field) => ({
documentId: document.id,
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
},
},
},
});
}),
);
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentData: true,
documentMeta: true,
recipients: true,
fields: true,
folder: true,
},
});
if (!createdDocument) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return createdDocument;
});
};
@@ -1,177 +0,0 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { DocumentVisibility } from '@prisma/client';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
title: string;
externalId?: string | null;
userId: number;
teamId: number;
documentDataId: string;
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
timezone?: string;
userTimezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
expiryAmount?: number;
expiryUnit?: string;
};
export const createDocument = async ({
userId,
title,
externalId,
documentDataId,
teamId,
normalizePdf,
formValues,
requestMetadata,
timezone,
userTimezone,
folderId,
expiryAmount,
expiryUnit,
}: CreateDocumentOptions) => {
const team = await getTeamById({ userId, teamId });
const settings = await getTeamSettings({
userId,
teamId,
});
let folderVisibility: DocumentVisibility | undefined;
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
select: {
visibility: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
folderVisibility = folder.visibility;
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFileServerSide({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId,
documentDataId,
userId,
teamId,
folderId,
visibility:
folderVisibility ??
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
timezone: timezoneToUse,
expiryAmount,
expiryUnit,
}),
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentMeta: true,
recipients: true,
},
});
if (!createdDocument) {
throw new Error('Document not found');
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return createdDocument;
});
};
@@ -26,7 +26,6 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@@ -211,39 +210,6 @@ export const resendDocument = async ({
text,
});
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
const previousExpiryDate = recipient.expired;
const newExpiryDate = calculateRecipientExpiry(
envelope.documentMeta.expiryAmount,
envelope.documentMeta.expiryUnit,
new Date(),
);
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
expired: newExpiryDate,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
previousExpiryDate,
newExpiryDate,
},
}),
});
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
@@ -81,11 +81,15 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
const { user: owner } = envelope;
const completedDocumentEmailAttachments = await Promise.all(
envelope.envelopeItems.map(async (document) => {
const file = await getFileServerSide(document.documentData);
envelope.envelopeItems.map(async (envelopeItem) => {
const file = await getFileServerSide(envelopeItem.documentData);
// Use the envelope title for version 1, and the envelope item title for version 2.
const fileNameToUse =
envelope.internalVersion === 1 ? envelope.title : envelopeItem.title + '.pdf';
return {
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
content: Buffer.from(file),
contentType: 'application/pdf',
};
@@ -24,7 +24,6 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -178,24 +177,6 @@ export const sendDocument = async ({
});
}
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
const expiryDate = calculateRecipientExpiry(
envelope.documentMeta.expiryAmount,
envelope.documentMeta.expiryUnit,
new Date(), // Calculate from current time
);
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
expired: null,
},
data: {
expired: expiryDate,
},
});
}
return await tx.envelope.update({
where: {
id: envelope.id,
@@ -25,9 +25,7 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientExpired } from '../../utils/expiry';
import { validateFieldAuth } from '../document/validate-field-auth';
import { expireRecipient } from '../recipient/expire-recipient';
export type SignFieldWithTokenOptions = {
token: string;
@@ -117,11 +115,6 @@ export const signFieldWithToken = async ({
throw new Error(`Recipient ${recipient.id} has already signed`);
}
if (isRecipientExpired(recipient)) {
await expireRecipient({ recipientId: recipient.id });
throw new Error(`Signing link has expired`);
}
if (field.inserted) {
throw new Error(`Field ${fieldId} has already been inserted`);
}
@@ -1,7 +1,9 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamSettings } from '../team/get-team-settings';
export interface CreateFolderOptions {
@@ -22,6 +24,27 @@ export const createFolder = async ({
// This indirectly verifies whether the user has access to the team.
const settings = await getTeamSettings({ userId, teamId });
if (parentId) {
const parentFolder = await prisma.folder.findFirst({
where: {
id: parentId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentFolder.type !== type) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Parent folder type does not match the folder type',
});
}
}
return await prisma.folder.create({
data: {
name,
@@ -1,6 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
@@ -20,6 +21,9 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
teamId,
userId,
}),
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
},
});
@@ -39,7 +43,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
return await prisma.folder.delete({
where: {
id: folderId,
id: folder.id,
},
});
};
@@ -0,0 +1,117 @@
import { EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
import { getTeamById } from '../team/get-team';
export interface FindFoldersInternalOptions {
userId: number;
teamId: number;
parentId?: string | null;
type?: TFolderType;
}
export const findFoldersInternal = async ({
userId,
teamId,
parentId,
type,
}: FindFoldersInternalOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = {
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
const whereClause = {
AND: [
{ parentId },
{
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
},
],
};
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
folderId: folder.id,
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
};
+36 -88
View File
@@ -1,9 +1,11 @@
import { EnvelopeType } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
import type { FindResultResponse } from '../../types/search-params';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface FindFoldersOptions {
@@ -11,102 +13,48 @@ export interface FindFoldersOptions {
teamId: number;
parentId?: string | null;
type?: TFolderType;
page?: number;
perPage?: number;
}
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
export const findFolders = async ({
userId,
teamId,
parentId,
type,
page = 1,
perPage = 10,
}: FindFoldersOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = {
const whereClause: Prisma.FolderWhereInput = {
parentId,
team: buildTeamWhereQuery({ teamId, userId }),
type,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
const whereClause = {
AND: [
{ parentId },
{
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
const [data, count] = await Promise.all([
prisma.folder.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
},
],
};
}),
prisma.folder.count({
where: whereClause,
}),
]);
try {
const folders = await prisma.folder.findMany({
where: {
...whereClause,
...(type ? { type } : {}),
},
orderBy: [{ pinned: 'desc' }, { createdAt: 'desc' }],
});
const foldersWithDetails = await Promise.all(
folders.map(async (folder) => {
try {
const [subfolders, documentCount, templateCount, subfolderCount] = await Promise.all([
prisma.folder.findMany({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
orderBy: {
createdAt: 'desc',
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.DOCUMENT,
folderId: folder.id,
},
}),
prisma.envelope.count({
where: {
type: EnvelopeType.TEMPLATE,
folderId: folder.id,
},
}),
prisma.folder.count({
where: {
parentId: folder.id,
teamId,
...visibilityFilters,
},
}),
]);
const subfoldersWithEmptySubfolders = subfolders.map((subfolder) => ({
...subfolder,
subfolders: [],
_count: {
documents: 0,
templates: 0,
subfolders: 0,
},
}));
return {
...folder,
subfolders: subfoldersWithEmptySubfolders,
_count: {
documents: documentCount,
templates: templateCount,
subfolders: subfolderCount,
},
};
} catch (error) {
console.error('Error processing folder:', folder.id, error);
throw error;
}
}),
);
return foldersWithDetails;
} catch (error) {
console.error('Error in findFolders:', error);
throw error;
}
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
};
@@ -1,51 +1,30 @@
import { TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '../../types/document-visibility';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface GetFolderByIdOptions {
userId: number;
teamId: number;
folderId?: string;
folderId: string;
type?: TFolderType;
}
export const getFolderById = async ({ userId, teamId, folderId, type }: GetFolderByIdOptions) => {
const team = await getTeamById({ userId, teamId });
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
DocumentVisibility.ADMIN,
],
},
}))
.with(TeamMemberRole.MANAGER, () => ({
visibility: {
in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE],
},
}))
.otherwise(() => ({ visibility: DocumentVisibility.EVERYONE }));
const whereClause = {
id: folderId,
...(type ? { type } : {}),
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
};
const folder = await prisma.folder.findFirst({
where: whereClause,
where: {
id: folderId,
team: buildTeamWhereQuery({ teamId, userId }),
type,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
},
});
if (!folder) {
@@ -1,89 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface MoveFolderOptions {
userId: number;
teamId?: number;
folderId?: string;
parentId?: string | null;
requestMetadata?: ApiRequestMetadata;
}
export const moveFolder = async ({ userId, teamId, folderId, parentId }: MoveFolderOptions) => {
return await prisma.$transaction(async (tx) => {
const folder = await tx.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
if (parentId) {
const parentFolder = await tx.folder.findFirst({
where: {
id: parentId,
userId,
teamId,
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await tx.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await tx.folder.update({
where: {
id: folderId,
},
data: {
parentId,
},
});
});
};
@@ -1,40 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface PinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const pinFolder = async ({ userId, teamId, folderId, type }: PinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: true,
},
});
};
@@ -1,40 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { TFolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
export interface UnpinFolderOptions {
userId: number;
teamId?: number;
folderId: string;
type?: TFolderType;
}
export const unpinFolder = async ({ userId, teamId, folderId, type }: UnpinFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
type,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
return await prisma.folder.update({
where: {
id: folderId,
},
data: {
pinned: false,
},
});
};
@@ -1,28 +1,28 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import type { DocumentVisibility } from '@documenso/prisma/generated/types';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface UpdateFolderOptions {
userId: number;
teamId?: number;
teamId: number;
folderId: string;
name: string;
visibility: DocumentVisibility;
type?: TFolderType;
data: {
parentId?: string | null;
name?: string;
visibility?: DocumentVisibility;
pinned?: boolean;
};
}
export const updateFolder = async ({
userId,
teamId,
folderId,
name,
visibility,
type,
}: UpdateFolderOptions) => {
export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => {
const { parentId, name, visibility, pinned } = data;
const team = await getTeamById({ userId, teamId });
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
@@ -30,7 +30,9 @@ export const updateFolder = async ({
teamId,
userId,
}),
type,
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
},
});
@@ -40,17 +42,66 @@ export const updateFolder = async ({
});
}
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
const effectiveVisibility =
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
if (parentId) {
const parentFolder = await prisma.folder.findFirst({
where: {
id: parentId,
team: buildTeamWhereQuery({ teamId, userId }),
type: folder.type,
},
});
if (!parentFolder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
if (parentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into itself',
});
}
let currentParentId = parentFolder.parentId;
while (currentParentId) {
if (currentParentId === folderId) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot move a folder into its descendant',
});
}
const currentParent = await prisma.folder.findUnique({
where: {
id: currentParentId,
},
select: {
parentId: true,
},
});
if (!currentParent) {
break;
}
currentParentId = currentParent.parentId;
}
}
return await prisma.folder.update({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
data: {
name,
visibility: effectiveVisibility,
visibility,
parentId,
pinned,
},
});
};
@@ -1,36 +0,0 @@
import { SigningStatus } from '@prisma/client';
import { prisma } from '@documenso/prisma';
export type ExpireRecipientOptions = {
recipientId: number;
};
export const expireRecipient = async ({ recipientId }: ExpireRecipientOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
},
select: {
id: true,
signingStatus: true,
},
});
if (!recipient) {
return null;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return recipient;
}
return await prisma.recipient.update({
where: {
id: recipientId,
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
};
@@ -52,7 +52,6 @@ import {
} from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
@@ -111,8 +110,6 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
expiryAmount?: number;
expiryUnit?: string;
};
requestMetadata: ApiRequestMetadata;
};
@@ -511,16 +508,6 @@ export const createDocumentFromTemplate = async ({
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
// Calculate expiry date based on override
// Note: Templates no longer have default expiry settings (TemplateMeta removed)
const expiryAmount = override?.expiryAmount ?? null;
const expiryUnit = override?.expiryUnit ?? null;
const recipientExpiryDate = calculateRecipientExpiry(
expiryAmount,
expiryUnit,
new Date(), // Calculate from current time
);
return {
email: recipient.email,
name: recipient.name,
@@ -536,7 +523,6 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
expired: recipientExpiryDate,
token: recipient.token,
};
}),
@@ -1,22 +1,23 @@
import { DocumentStatus } from '@prisma/client';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
export const getCompletedDocumentsMonthly = async () => {
const qb = kyselyPrisma.$kysely
.selectFrom('Document')
.selectFrom('Envelope')
.select(({ fn }) => [
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']).as('month'),
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
fn.count('id').as('count'),
fn
.sum(fn.count('id'))
// Feels like a bug in the Kysely extension but I just can not do this orderBy in a type-safe manner
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']) as any))
.as('cume_count'),
])
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
.groupBy('month')
.orderBy('month', 'desc')
.limit(12);
-52
View File
@@ -267,11 +267,6 @@ msgstr "{prefix} hat das Dokument erstellt"
msgid "{prefix} deleted the document"
msgstr "{prefix} hat das Dokument gelöscht"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} hat das Dokument ins Team verschoben"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Zeitüberschreitung überschritten"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Abgelaufen"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "Link läuft in 1 Stunde ab."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Vorlage verlinken"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Links generiert"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Persönliches Konto"
msgid "Personal Inbox"
msgstr "Persönlicher Posteingang"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Empfänger"
msgid "Recipient action authentication"
msgstr "Empfängeraktion Authentifizierung"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "E-Mail des entfernten Empfängers"
@@ -7131,10 +7095,6 @@ msgstr "Sitzungen wurden widerrufen"
msgid "Set a password"
msgstr "Ein Passwort festlegen"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
@@ -7433,10 +7393,6 @@ msgstr "Unterzeichne für"
msgid "Signing in..."
msgstr "Anmeldung..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8311,10 +8267,6 @@ msgstr "Der Name des Unterzeichners"
msgid "The signing link has been copied to your clipboard."
msgstr "Der Signierlink wurde in die Zwischenablage kopiert."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Das Seitenbanner ist eine Nachricht, die oben auf der Seite angezeigt wird. Es kann verwendet werden, um Ihren Nutzern wichtige Informationen anzuzeigen."
@@ -8633,10 +8585,6 @@ msgstr "Diese Sitzung ist abgelaufen. Bitte versuchen Sie es erneut."
msgid "This signer has already signed the document."
msgstr "Dieser Unterzeichner hat das Dokument bereits unterschrieben."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Dieses Team und alle zugehörigen Daten, ausgenommen Rechnungen, werden permanent gelöscht."
-52
View File
@@ -262,11 +262,6 @@ msgstr "{prefix} created the document"
msgid "{prefix} deleted the document"
msgstr "{prefix} deleted the document"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr "{prefix} extended expiry for {0}"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} moved the document to team"
@@ -1705,10 +1700,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr "Assisting"
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr "at"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4080,18 +4071,9 @@ msgid "Exceeded timeout"
msgstr "Exceeded timeout"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expired"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr "Expired on: {0}"
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4971,11 +4953,6 @@ msgstr "Link expires in 1 hour."
msgid "Link expires in 30 minutes."
msgstr "Link expires in 30 minutes."
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr "Link Expiry"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Link template"
@@ -4997,11 +4974,6 @@ msgstr "Linked At"
msgid "Links Generated"
msgstr "Links Generated"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr "Links will expire on: {0}"
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5990,10 +5962,6 @@ msgstr "Personal Account"
msgid "Personal Inbox"
msgstr "Personal Inbox"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr "Pick a date"
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6408,10 +6376,6 @@ msgstr "Recipient"
msgid "Recipient action authentication"
msgstr "Recipient action authentication"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr "Recipient expiry extended"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Recipient removed email"
@@ -7126,10 +7090,6 @@ msgstr "Sessions have been revoked"
msgid "Set a password"
msgstr "Set a password"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr "Set an expiry duration for signing links (leave empty to disable)"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Set up your document properties and recipient information"
@@ -7428,10 +7388,6 @@ msgstr "Signing for"
msgid "Signing in..."
msgstr "Signing in..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr "Signing Link Expired"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8316,10 +8272,6 @@ msgstr "The signer's name"
msgid "The signing link has been copied to your clipboard."
msgstr "The signing link has been copied to your clipboard."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
@@ -8648,10 +8600,6 @@ msgstr "This session has expired. Please try again."
msgid "This signer has already signed the document."
msgstr "This signer has already signed the document."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr "This signing link is no longer valid"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "This team, and any associated data excluding billing invoices will be permanently deleted."
-52
View File
@@ -267,11 +267,6 @@ msgstr "{prefix} creó el documento"
msgid "{prefix} deleted the document"
msgstr "{prefix} eliminó el documento"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} movió el documento al equipo"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Tiempo de espera excedido"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expirado"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "El enlace expira en 1 hora."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Enlace de plantilla"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Enlaces generados"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Cuenta personal"
msgid "Personal Inbox"
msgstr "Bandeja de entrada personal"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Destinatario"
msgid "Recipient action authentication"
msgstr "Autenticación de acción de destinatario"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Correo electrónico de destinatario eliminado"
@@ -7131,10 +7095,6 @@ msgstr "Las sesiones han sido revocadas"
msgid "Set a password"
msgstr "Establecer una contraseña"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configura las propiedades de tu documento y la información del destinatario"
@@ -7433,10 +7393,6 @@ msgstr "Firmando para"
msgid "Signing in..."
msgstr "Iniciando sesión..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8311,10 +8267,6 @@ msgstr "El nombre del firmante"
msgid "The signing link has been copied to your clipboard."
msgstr "El enlace de firma ha sido copiado a tu portapapeles."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "El banner del sitio es un mensaje que se muestra en la parte superior del sitio. Se puede usar para mostrar información importante a tus usuarios."
@@ -8635,10 +8587,6 @@ msgstr "Esta sesión ha expirado. Por favor, inténtalo de nuevo."
msgid "This signer has already signed the document."
msgstr "Este firmante ya ha firmado el documento."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Este equipo, y cualquier dato asociado, excluyendo las facturas de facturación, serán eliminados permanentemente."
-52
View File
@@ -267,11 +267,6 @@ msgstr "{prefix} a créé le document"
msgid "{prefix} deleted the document"
msgstr "{prefix} a supprimé le document"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} a déplacé le document vers l'équipe"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Délai dépassé"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expiré"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "Le lien expire dans 1 heure."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Modèle de lien"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Liens générés"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Compte personnel"
msgid "Personal Inbox"
msgstr "Boîte de réception personnelle"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Destinataire"
msgid "Recipient action authentication"
msgstr "Authentification d'action de destinataire"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "E-mail de destinataire supprimé"
@@ -7131,10 +7095,6 @@ msgstr "Les sessions ont été révoquées"
msgid "Set a password"
msgstr "Définir un mot de passe"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configurez les propriétés de votre document et les informations du destinataire"
@@ -7433,10 +7393,6 @@ msgstr "Signé pour"
msgid "Signing in..."
msgstr "Connexion en cours..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8311,10 +8267,6 @@ msgstr "Le nom du signataire"
msgid "The signing link has been copied to your clipboard."
msgstr "Le lien de signature a été copié dans votre presse-papiers."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "La bannière du site est un message affiché en haut du site. Elle peut être utilisée pour afficher des informations importantes à vos utilisateurs."
@@ -8633,10 +8585,6 @@ msgstr "Cette session a expiré. Veuillez réessayer."
msgid "This signer has already signed the document."
msgstr "Ce signataire a déjà signé le document."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Cette équipe, et toutes les données associées à l'exception des factures de facturation, seront définitivement supprimées."
-52
View File
@@ -267,11 +267,6 @@ msgstr "{prefix} ha creato il documento"
msgid "{prefix} deleted the document"
msgstr "{prefix} ha eliminato il documento"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} ha spostato il documento al team"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Tempo scaduto"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Scaduto"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "Il link scade tra 1 ora."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Collega modello"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Link Generati"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Account personale"
msgid "Personal Inbox"
msgstr "Posta in arrivo personale"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Destinatario"
msgid "Recipient action authentication"
msgstr "Autenticazione azione destinatario"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Email destinatario rimosso"
@@ -7131,10 +7095,6 @@ msgstr "Le sessioni sono state revocate"
msgid "Set a password"
msgstr "Imposta una password"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
@@ -7433,10 +7393,6 @@ msgstr "Firma per"
msgid "Signing in..."
msgstr "Accesso in corso..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8319,10 +8275,6 @@ msgstr "Il nome del firmatario"
msgid "The signing link has been copied to your clipboard."
msgstr "Il link di firma è stato copiato negli appunti."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Il banner del sito è un messaggio che viene mostrato in cima al sito. Può essere utilizzato per visualizzare informazioni importanti ai tuoi utenti."
@@ -8649,10 +8601,6 @@ msgstr "Questa sessione è scaduta. Per favore prova di nuovo."
msgid "This signer has already signed the document."
msgstr "Questo firmatario ha già firmato il documento."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Questo team e tutti i dati associati, escluse le fatture di fatturazione, verranno eliminati definitivamente."
-52
View File
@@ -267,11 +267,6 @@ msgstr "Użytkownik {prefix} utworzył dokument"
msgid "{prefix} deleted the document"
msgstr "Użytkownik {prefix} usunął dokument"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "Użytkownik {prefix} przeniósł dokument do zespołu"
@@ -1710,10 +1705,6 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@@ -4085,18 +4076,9 @@ msgid "Exceeded timeout"
msgstr "Przekroczono limit czasu"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Wygasł"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@@ -4976,11 +4958,6 @@ msgstr "Link wygaśnie za 1 godzinę."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Szablon linku"
@@ -5002,11 +4979,6 @@ msgstr ""
msgid "Links Generated"
msgstr "Wygenerowane linki"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@@ -5995,10 +5967,6 @@ msgstr "Konto osobiste"
msgid "Personal Inbox"
msgstr "Skrzynka odbiorcza osobista"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@@ -6413,10 +6381,6 @@ msgstr "Odbiorca"
msgid "Recipient action authentication"
msgstr "Uwierzytelnianie odbiorcy"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Wiadomość o usuniętym odbiorcy"
@@ -7131,10 +7095,6 @@ msgstr "Sesje zostały odwołane"
msgid "Set a password"
msgstr "Ustaw hasło"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
@@ -7433,10 +7393,6 @@ msgstr "Podpis w imieniu"
msgid "Signing in..."
msgstr "Logowanie..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@@ -8311,10 +8267,6 @@ msgstr "Nazwa podpisującego"
msgid "The signing link has been copied to your clipboard."
msgstr "Link do podpisu został skopiowany do schowka."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "The signing link has expired and can no longer be used to sign the document. Please contact the document sender if you need a new signing link."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Baner strony to wiadomość, która jest wyświetlana u góry strony. Może być używany do wyświetlania ważnych informacji użytkownikom."
@@ -8633,10 +8585,6 @@ msgstr "Ta sesja wygasła. Proszę spróbować ponownie."
msgid "This signer has already signed the document."
msgstr "Ten sygnatariusz już podpisał dokument."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Ten zespół oraz wszelkie powiązane dane, z wyjątkiem faktur, zostaną trwale usunięte."
-16
View File
@@ -40,7 +40,6 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
'DOCUMENT_RECIPIENT_EXPIRY_EXTENDED', // When a recipient's expiry is extended via resend.
// ACCESS AUTH 2FA events.
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
@@ -640,20 +639,6 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
}),
});
/**
* Event: Recipient expiry extended.
*/
export const ZDocumentAuditLogEventRecipientExpiryExtendedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED),
data: z.object({
recipientId: z.number(),
recipientName: z.string().optional(),
recipientEmail: z.string(),
previousExpiryDate: z.date().nullable(),
newExpiryDate: z.date().nullable(),
}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@@ -695,7 +680,6 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventRecipientExpiryExtendedSchema,
]),
);
-12
View File
@@ -107,16 +107,6 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using an uploaded signature.');
export const ZDocumentExpiryAmountSchema = z
.number()
.int()
.min(1)
.describe('The amount for expiry duration (e.g., 3 for "3 days").');
export const ZDocumentExpiryUnitSchema = z
.enum(['minutes', 'hours', 'days', 'weeks', 'months'])
.describe('The unit for expiry duration (e.g., "days" for "3 days").');
/**
* Note: Any updates to this will cause public API changes. You will need to update
* all corresponding areas where this is used (some places that use this needs to pass
@@ -138,8 +128,6 @@ export const ZDocumentMetaCreateSchema = z.object({
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
});
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;
-2
View File
@@ -69,8 +69,6 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailSettings: true,
emailId: true,
emailReplyTo: true,
expiryAmount: true,
expiryUnit: true,
}).extend({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),
@@ -515,10 +515,6 @@ export const formatDocumentAuditLogAction = (
context: `Audit log format`,
}),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED }, (data) => ({
anonymous: msg`Recipient expiry extended`,
identified: msg`${prefix} extended expiry for ${data.data.recipientEmail}`,
}))
.exhaustive();
return {
-24
View File
@@ -20,26 +20,6 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
};
const getExpiryAmount = (meta: Partial<DocumentMeta> | undefined | null): number | null => {
if (!meta) return null;
if ('expiryAmount' in meta && meta.expiryAmount !== undefined) {
return meta.expiryAmount;
}
return null;
};
const getExpiryUnit = (meta: Partial<DocumentMeta> | undefined | null): string | null => {
if (!meta) return null;
if ('expiryUnit' in meta && meta.expiryUnit !== undefined) {
return meta.expiryUnit;
}
return null;
};
/**
* Extracts the derived document meta which should be used when creating a document
* from scratch, or from a template.
@@ -82,10 +62,6 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
// Expiry settings.
expiryAmount: getExpiryAmount(meta),
expiryUnit: getExpiryUnit(meta),
} satisfies Omit<DocumentMeta, 'id'>;
};
-72
View File
@@ -1,72 +0,0 @@
import type { Recipient } from '@prisma/client';
import { DateTime } from 'luxon';
export interface DurationValue {
amount: number;
unit: string;
}
export const calculateRecipientExpiry = (
documentExpiryAmount?: number | null,
documentExpiryUnit?: string | null,
fromDate: Date = new Date(),
): Date | null => {
if (!documentExpiryAmount || !documentExpiryUnit) {
return null;
}
switch (documentExpiryUnit) {
case 'minutes':
return DateTime.fromJSDate(fromDate).plus({ minutes: documentExpiryAmount }).toJSDate();
case 'hours':
return DateTime.fromJSDate(fromDate).plus({ hours: documentExpiryAmount }).toJSDate();
case 'days':
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
case 'weeks':
return DateTime.fromJSDate(fromDate).plus({ weeks: documentExpiryAmount }).toJSDate();
case 'months':
return DateTime.fromJSDate(fromDate).plus({ months: documentExpiryAmount }).toJSDate();
default:
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
}
};
export const isRecipientExpired = (recipient: Recipient): boolean => {
if (!recipient.expired) {
return false;
}
return DateTime.now() > DateTime.fromJSDate(recipient.expired);
};
export const isValidExpirySettings = (
expiryAmount?: number | null,
expiryUnit?: string | null,
): boolean => {
if (!expiryAmount || !expiryUnit) {
return true;
}
return expiryAmount > 0 && ['minutes', 'hours', 'days', 'weeks', 'months'].includes(expiryUnit);
};
export const calculateExpiryDate = (duration: DurationValue, fromDate: Date = new Date()): Date => {
switch (duration.unit) {
case 'minutes':
return DateTime.fromJSDate(fromDate).plus({ minutes: duration.amount }).toJSDate();
case 'hours':
return DateTime.fromJSDate(fromDate).plus({ hours: duration.amount }).toJSDate();
case 'days':
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
case 'weeks':
return DateTime.fromJSDate(fromDate).plus({ weeks: duration.amount }).toJSDate();
case 'months':
return DateTime.fromJSDate(fromDate).plus({ months: duration.amount }).toJSDate();
default:
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
}
};
export const formatExpiryDate = (date: Date): string => {
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy HH:mm');
};
@@ -1,6 +0,0 @@
-- AlterEnum
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
ADD COLUMN "expiryUnit" TEXT;
-4
View File
@@ -505,9 +505,6 @@ model DocumentMeta {
emailReplyTo String?
emailId String?
expiryAmount Int?
expiryUnit String?
envelope Envelope?
}
@@ -525,7 +522,6 @@ enum SigningStatus {
NOT_SIGNED
SIGNED
REJECTED
EXPIRED
}
enum RecipientRole {
@@ -6,7 +6,6 @@ import { createDocumentData } from '@documenso/lib/server-only/document-data/cre
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
@@ -38,17 +37,8 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
recipients,
meta,
folderId,
expiryAmount,
expiryUnit,
} = input;
// Validate expiry settings
if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid expiry settings. Please check your expiry configuration.',
});
}
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
@@ -99,8 +89,6 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,
expiryAmount,
expiryUnit,
},
requestMetadata: ctx.metadata,
});
@@ -19,8 +19,6 @@ import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
@@ -53,8 +51,6 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({
@@ -4,7 +4,6 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { authenticatedProcedure } from '../trpc';
import {
@@ -17,14 +16,7 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId } = ctx;
const { title, documentDataId, timezone, folderId, expiryAmount, expiryUnit } = input;
// Validate expiry settings
if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid expiry settings. Please check your expiry configuration.',
});
}
const { title, documentDataId, timezone, folderId } = input;
ctx.logger.info({
input: {
@@ -56,10 +48,6 @@ export const createDocumentRoute = authenticatedProcedure
},
],
},
meta: {
expiryAmount,
expiryUnit,
},
normalizePdf: true,
requestMetadata: ctx.metadata,
});
@@ -1,10 +1,6 @@
import { z } from 'zod';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { ZDocumentTitleSchema } from './schema';
@@ -23,8 +19,6 @@ export const ZCreateDocumentRequestSchema = z.object({
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
});
export const ZCreateDocumentResponseSchema = z.object({
@@ -1,11 +1,6 @@
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
} from '@documenso/lib/types/document-meta';
/**
* Required for empty responses since we currently can't 201 requests for our openapi setup.
*
@@ -35,6 +30,3 @@ export const ZDocumentExternalIdSchema = z
export const ZDocumentVisibilitySchema = z
.nativeEnum(DocumentVisibility)
.describe('The visibility of the document.');
// Re-export expiry schemas for convenience
export { ZDocumentExpiryAmountSchema, ZDocumentExpiryUnitSchema };
@@ -1,7 +1,5 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { authenticatedProcedure } from '../trpc';
import {
@@ -29,15 +27,6 @@ export const updateDocumentRoute = authenticatedProcedure
const userId = ctx.user.id;
if (
(meta.expiryAmount || meta.expiryUnit) &&
!isValidExpirySettings(meta.expiryAmount, meta.expiryUnit)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid expiry settings. Please check your expiry configuration.',
});
}
const envelope = await updateEnvelope({
userId,
teamId,
+90 -149
View File
@@ -2,27 +2,26 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createFolder } from '@documenso/lib/server-only/folder/create-folder';
import { deleteFolder } from '@documenso/lib/server-only/folder/delete-folder';
import { findFolders } from '@documenso/lib/server-only/folder/find-folders';
import { findFoldersInternal } from '@documenso/lib/server-only/folder/find-folders-internal';
import { getFolderBreadcrumbs } from '@documenso/lib/server-only/folder/get-folder-breadcrumbs';
import { getFolderById } from '@documenso/lib/server-only/folder/get-folder-by-id';
import { moveFolder } from '@documenso/lib/server-only/folder/move-folder';
import { pinFolder } from '@documenso/lib/server-only/folder/pin-folder';
import { unpinFolder } from '@documenso/lib/server-only/folder/unpin-folder';
import { updateFolder } from '@documenso/lib/server-only/folder/update-folder';
import { authenticatedProcedure, router } from '../trpc';
import {
ZCreateFolderSchema,
ZDeleteFolderSchema,
ZCreateFolderRequestSchema,
ZCreateFolderResponseSchema,
ZDeleteFolderRequestSchema,
ZFindFoldersInternalRequestSchema,
ZFindFoldersInternalResponseSchema,
ZFindFoldersRequestSchema,
ZFindFoldersResponseSchema,
ZGenericSuccessResponse,
ZGetFoldersResponseSchema,
ZGetFoldersSchema,
ZMoveFolderSchema,
ZPinFolderSchema,
ZSuccessResponseSchema,
ZUnpinFolderSchema,
ZUpdateFolderSchema,
ZUpdateFolderRequestSchema,
ZUpdateFolderResponseSchema,
} from './schema';
export const folderRouter = router({
@@ -43,7 +42,7 @@ export const folderRouter = router({
},
});
const folders = await findFolders({
const folders = await findFoldersInternal({
userId: user.id,
teamId,
parentId,
@@ -67,11 +66,47 @@ export const folderRouter = router({
}),
/**
* @private
* @public
*/
findFolders: authenticatedProcedure
.meta({
openapi: {
method: 'GET',
path: '/folder',
summary: 'Find folders',
description: 'Find folders based on a search criteria',
tags: ['Folder'],
},
})
.input(ZFindFoldersRequestSchema)
.output(ZFindFoldersResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { parentId, type, page, perPage } = input;
ctx.logger.info({
input: {
parentId,
type,
},
});
return await findFolders({
userId: user.id,
teamId,
parentId,
type,
page,
perPage,
});
}),
/**
* @private
*/
findFoldersInternal: authenticatedProcedure
.input(ZFindFoldersInternalRequestSchema)
.output(ZFindFoldersInternalResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { parentId, type } = input;
@@ -83,7 +118,7 @@ export const folderRouter = router({
},
});
const folders = await findFolders({
const folders = await findFoldersInternal({
userId: user.id,
teamId,
parentId,
@@ -107,10 +142,20 @@ export const folderRouter = router({
}),
/**
* @private
* @public
*/
createFolder: authenticatedProcedure
.input(ZCreateFolderSchema)
.meta({
openapi: {
method: 'POST',
path: '/folder/create',
summary: 'Create new folder',
description: 'Creates a new folder in your team',
tags: ['Folder'],
},
})
.input(ZCreateFolderRequestSchema)
.output(ZCreateFolderResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { name, parentId, type } = input;
@@ -145,181 +190,77 @@ export const folderRouter = router({
type,
});
return {
...result,
type,
};
return result;
}),
/**
* @private
* @public
*/
updateFolder: authenticatedProcedure
.input(ZUpdateFolderSchema)
.meta({
openapi: {
method: 'POST',
path: '/folder/update',
summary: 'Update folder',
description: 'Updates an existing folder',
tags: ['Folder'],
},
})
.input(ZUpdateFolderRequestSchema)
.output(ZUpdateFolderResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { id, name, visibility } = input;
const { folderId, data } = input;
ctx.logger.info({
input: {
id,
folderId,
},
});
const currentFolder = await getFolderById({
userId: user.id,
teamId,
folderId: id,
});
const result = await updateFolder({
userId: user.id,
teamId,
folderId: id,
name,
visibility,
type: currentFolder.type,
folderId,
data,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @private
* @public
*/
deleteFolder: authenticatedProcedure
.input(ZDeleteFolderSchema)
.meta({
openapi: {
method: 'POST',
path: '/folder/delete',
summary: 'Delete folder',
description: 'Deletes an existing folder',
tags: ['Folder'],
},
})
.input(ZDeleteFolderRequestSchema)
.output(ZSuccessResponseSchema)
.mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { id } = input;
const { folderId } = input;
ctx.logger.info({
input: {
id,
folderId,
},
});
await deleteFolder({
userId: user.id,
teamId,
folderId: id,
folderId,
});
return ZGenericSuccessResponse;
}),
/**
* @private
*/
moveFolder: authenticatedProcedure.input(ZMoveFolderSchema).mutation(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { id, parentId } = input;
ctx.logger.info({
input: {
id,
parentId,
},
});
const currentFolder = await getFolderById({
userId: user.id,
teamId,
folderId: id,
});
if (parentId !== null) {
try {
await getFolderById({
userId: user.id,
teamId,
folderId: parentId,
type: currentFolder.type,
});
} catch (error) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Parent folder not found',
});
}
}
const result = await moveFolder({
userId: user.id,
teamId,
folderId: id,
parentId,
requestMetadata: ctx.metadata,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @private
*/
pinFolder: authenticatedProcedure.input(ZPinFolderSchema).mutation(async ({ ctx, input }) => {
const { folderId } = input;
ctx.logger.info({
input: {
folderId,
},
});
const currentFolder = await getFolderById({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId,
});
const result = await pinFolder({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId,
type: currentFolder.type,
});
return {
...result,
type: currentFolder.type,
};
}),
/**
* @private
*/
unpinFolder: authenticatedProcedure.input(ZUnpinFolderSchema).mutation(async ({ ctx, input }) => {
const { folderId } = input;
ctx.logger.info({
input: {
folderId,
},
});
const currentFolder = await getFolderById({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId,
});
const result = await unpinFolder({
userId: ctx.user.id,
teamId: ctx.teamId,
folderId,
type: currentFolder.type,
});
return {
...result,
type: currentFolder.type,
};
}),
});
+47 -39
View File
@@ -1,8 +1,9 @@
import { z } from 'zod';
import { ZFolderTypeSchema } from '@documenso/lib/types/folder-type';
import { ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import FolderSchema from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
/**
* Required for empty responses since we currently can't 201 requests for our openapi setup.
@@ -11,24 +12,23 @@ import { DocumentVisibility } from '@documenso/prisma/generated/types';
*/
export const ZSuccessResponseSchema = z.object({
success: z.boolean(),
type: ZFolderTypeSchema.optional(),
});
export const ZGenericSuccessResponse = {
success: true,
} satisfies z.infer<typeof ZSuccessResponseSchema>;
export const ZFolderSchema = z.object({
id: z.string(),
name: z.string(),
userId: z.number(),
teamId: z.number().nullable(),
parentId: z.string().nullable(),
pinned: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
visibility: z.nativeEnum(DocumentVisibility),
type: ZFolderTypeSchema,
export const ZFolderSchema = FolderSchema.pick({
id: true,
name: true,
userId: true,
teamId: true,
parentId: true,
pinned: true,
createdAt: true,
updatedAt: true,
visibility: true,
type: true,
});
export type TFolder = z.infer<typeof ZFolderSchema>;
@@ -51,40 +51,39 @@ export const ZFolderWithSubfoldersSchema = ZFolderSchema.extend({
export type TFolderWithSubfolders = z.infer<typeof ZFolderWithSubfoldersSchema>;
export const ZCreateFolderSchema = z.object({
const ZFolderParentIdSchema = z
.string()
.describe(
'The folder ID to place this folder within. Leave empty to place folder at the root level.',
);
export const ZCreateFolderRequestSchema = z.object({
name: z.string(),
parentId: z.string().optional(),
parentId: ZFolderParentIdSchema.optional(),
type: ZFolderTypeSchema.optional(),
});
export const ZUpdateFolderSchema = z.object({
id: z.string(),
name: z.string(),
visibility: z.nativeEnum(DocumentVisibility),
type: ZFolderTypeSchema.optional(),
export const ZCreateFolderResponseSchema = ZFolderSchema;
export const ZUpdateFolderRequestSchema = z.object({
folderId: z.string().describe('The ID of the folder to update'),
data: z.object({
name: z.string().optional().describe('The name of the folder'),
parentId: ZFolderParentIdSchema.optional().nullable(),
visibility: z
.nativeEnum(DocumentVisibility)
.optional()
.describe('The visibility of the folder'),
pinned: z.boolean().optional().describe('Whether the folder should be pinned'),
}),
});
export type TUpdateFolderSchema = z.infer<typeof ZUpdateFolderSchema>;
export type TUpdateFolderRequestSchema = z.infer<typeof ZUpdateFolderRequestSchema>;
export const ZDeleteFolderSchema = z.object({
id: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZUpdateFolderResponseSchema = ZFolderSchema;
export const ZMoveFolderSchema = z.object({
id: z.string(),
parentId: z.string().nullable(),
type: ZFolderTypeSchema.optional(),
});
export const ZPinFolderSchema = z.object({
export const ZDeleteFolderRequestSchema = z.object({
folderId: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZUnpinFolderSchema = z.object({
folderId: z.string(),
type: ZFolderTypeSchema.optional(),
});
export const ZGetFoldersSchema = z.object({
@@ -101,11 +100,20 @@ export const ZGetFoldersResponseSchema = z.object({
export type TGetFoldersResponse = z.infer<typeof ZGetFoldersResponseSchema>;
export const ZFindFoldersRequestSchema = ZFindSearchParamsSchema.extend({
parentId: z.string().optional().describe('Filter folders by the parent folder ID'),
type: ZFolderTypeSchema.optional().describe('Filter folders by the folder type'),
});
export const ZFindFoldersResponseSchema = ZFindResultResponse.extend({
data: z.array(ZFolderSchema),
});
export const ZFindFoldersInternalRequestSchema = ZFindSearchParamsSchema.extend({
parentId: z.string().nullable().optional(),
type: ZFolderTypeSchema.optional(),
});
export const ZFindFoldersResponseSchema = z.object({
export const ZFindFoldersInternalResponseSchema = z.object({
data: z.array(ZFolderWithSubfoldersSchema),
breadcrumbs: z.array(ZFolderSchema),
type: ZFolderTypeSchema.optional(),
-131
View File
@@ -1,131 +0,0 @@
'use client';
import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CalendarIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { cn } from '../lib/utils';
import { Button } from './button';
import { Calendar } from './calendar';
import { Input } from './input';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
export interface DateTimePickerProps {
value?: Date;
onChange?: (date: Date | undefined) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
minDate?: Date;
}
export const DateTimePicker = ({
value,
onChange,
placeholder,
disabled = false,
className,
minDate = new Date(),
}: DateTimePickerProps) => {
const { _ } = useLingui();
const [open, setOpen] = React.useState(false);
const handleDateSelect = (selectedDate: Date | undefined) => {
if (!selectedDate) {
onChange?.(undefined);
return;
}
if (value) {
const existingTime = DateTime.fromJSDate(value);
const newDateTime = DateTime.fromJSDate(selectedDate).set({
hour: existingTime.hour,
minute: existingTime.minute,
});
onChange?.(newDateTime.toJSDate());
} else {
const now = DateTime.now();
const newDateTime = DateTime.fromJSDate(selectedDate).set({
hour: now.hour,
minute: now.minute,
});
onChange?.(newDateTime.toJSDate());
}
setOpen(false);
};
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const timeValue = event.target.value;
if (!timeValue || !value) return;
const [hours, minutes] = timeValue.split(':').map(Number);
const newDateTime = DateTime.fromJSDate(value).set({
hour: hours,
minute: minutes,
});
onChange?.(newDateTime.toJSDate());
};
const formatDateTime = (date: Date) => {
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy');
};
const formatTime = (date: Date) => {
return DateTime.fromJSDate(date).toFormat('HH:mm');
};
return (
<div className={cn('flex gap-2', className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-[200px] justify-start text-left font-normal',
!value && 'text-muted-foreground',
)}
disabled={disabled}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? formatDateTime(value) : <span>{placeholder || _(msg`Pick a date`)}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={handleDateSelect}
disabled={
disabled
? true
: (date) => {
return date < minDate;
}
}
initialFocus
/>
</PopoverContent>
</Popover>
{value && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
<Trans>at</Trans>
</span>
<Input
type="time"
value={formatTime(value)}
onChange={handleTimeChange}
disabled={disabled}
className="w-[120px]"
/>
</div>
)}
</div>
);
};
@@ -11,7 +11,7 @@ import {
TeamMemberRole,
} from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
@@ -57,7 +57,6 @@ import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combo
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox';
import { ExpirySettingsPicker } from '../expiry-settings-picker';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
@@ -73,18 +72,6 @@ import {
} from './document-flow-root';
import type { DocumentFlowStep } from './types';
const isExpiryUnit = (
value: unknown,
): value is 'minutes' | 'hours' | 'days' | 'weeks' | 'months' => {
return (
value === 'minutes' ||
value === 'hours' ||
value === 'days' ||
value === 'weeks' ||
value === 'months'
);
};
export type AddSettingsFormProps = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
@@ -114,9 +101,6 @@ export const AddSettingsFormPartial = ({
documentAuth: document.authOptions,
});
const documentExpiryUnit = document.documentMeta?.expiryUnit;
const initialExpiryUnit = isExpiryUnit(documentExpiryUnit) ? documentExpiryUnit : undefined;
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
@@ -136,8 +120,6 @@ export const AddSettingsFormPartial = ({
redirectUrl: document.documentMeta?.redirectUrl ?? '',
language: document.documentMeta?.language ?? 'en',
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
expiryAmount: document.documentMeta?.expiryAmount ?? undefined,
expiryUnit: initialExpiryUnit,
},
},
});
@@ -148,9 +130,6 @@ export const AddSettingsFormPartial = ({
(recipient) => recipient.sendStatus === SendStatus.SENT,
);
const expiryAmount = useWatch({ control: form.control, name: 'meta.expiryAmount' });
const expiryUnit = useWatch({ control: form.control, name: 'meta.expiryUnit' });
const canUpdateVisibility = match(currentTeamMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(
@@ -543,33 +522,6 @@ export const AddSettingsFormPartial = ({
</FormItem>
)}
/>
<div>
<FormLabel className="mb-4 block">
<Trans>Link Expiry</Trans>
</FormLabel>
<ExpirySettingsPicker
value={{
expiryDuration:
expiryAmount && expiryUnit
? {
amount: expiryAmount,
unit: expiryUnit,
}
: undefined,
}}
disabled={documentHasBeenSent}
onValueChange={(value) => {
if (value.expiryDuration) {
form.setValue('meta.expiryAmount', value.expiryDuration.amount);
form.setValue('meta.expiryUnit', value.expiryDuration.unit);
} else {
form.setValue('meta.expiryAmount', undefined);
form.setValue('meta.expiryUnit', undefined);
}
}}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
@@ -46,8 +46,6 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
expiryAmount: z.number().int().min(1).optional(),
expiryUnit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']).optional(),
}),
});
@@ -63,8 +63,6 @@ export type AddSignersFormProps = {
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
expiryAmount?: number | null;
expiryUnit?: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | null;
onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean;
@@ -76,8 +74,6 @@ export const AddSignersFormPartial = ({
fields,
signingOrder,
allowDictateNextSigner,
expiryAmount,
expiryUnit,
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
@@ -142,10 +138,6 @@ export const AddSignersFormPartial = ({
: defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
meta: {
expiryAmount: expiryAmount ?? undefined,
expiryUnit: expiryUnit ?? undefined,
},
},
});
@@ -3,10 +3,6 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
} from '@documenso/lib/types/document-meta';
export const ZAddSignersFormSchema = z.object({
signers: z.array(
@@ -25,10 +21,6 @@ export const ZAddSignersFormSchema = z.object({
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
meta: z.object({
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
}),
});
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
@@ -1,79 +0,0 @@
'use client';
import React from 'react';
import type { DurationValue } from '@documenso/lib/utils/expiry';
import { cn } from '../lib/utils';
import { Input } from './input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
export interface DurationSelectorProps {
value?: DurationValue;
onChange?: (value: DurationValue) => void;
disabled?: boolean;
className?: string;
minAmount?: number;
maxAmount?: number;
}
const TIME_UNITS: Array<{ value: string; label: string; labelPlural: string }> = [
{ value: 'minutes', label: 'Minute', labelPlural: 'Minutes' },
{ value: 'hours', label: 'Hour', labelPlural: 'Hours' },
{ value: 'days', label: 'Day', labelPlural: 'Days' },
{ value: 'weeks', label: 'Week', labelPlural: 'Weeks' },
{ value: 'months', label: 'Month', labelPlural: 'Months' },
];
export const DurationSelector = ({
value = { amount: 1, unit: 'days' },
onChange,
disabled = false,
className,
minAmount = 1,
maxAmount = 365,
}: DurationSelectorProps) => {
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const amount = parseInt(event.target.value, 10);
if (!isNaN(amount) && amount >= minAmount && amount <= maxAmount) {
onChange?.({ ...value, amount });
}
};
const handleUnitChange = (unit: string) => {
onChange?.({ ...value, unit });
};
const getUnitLabel = (unit: string, amount: number) => {
const unitConfig = TIME_UNITS.find((u) => u.value === unit);
if (!unitConfig) return unit;
return amount === 1 ? unitConfig.label : unitConfig.labelPlural;
};
return (
<div className={cn('flex items-center gap-2', className)}>
<Input
type="number"
value={value.amount}
onChange={handleAmountChange}
disabled={disabled}
min={minAmount}
max={maxAmount}
className="w-20"
/>
<Select value={value.unit} onValueChange={handleUnitChange} disabled={disabled}>
<SelectTrigger className="w-24">
<SelectValue>{getUnitLabel(value.unit, value.amount)}</SelectValue>
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{getUnitLabel(unit.value, value.amount)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -1,132 +0,0 @@
'use client';
import React from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { calculateExpiryDate, formatExpiryDate } from '@documenso/lib/utils/expiry';
import { cn } from '../lib/utils';
import { DurationSelector } from './duration-selector';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from './form/form';
const ZExpirySettingsSchema = z.object({
expiryDuration: z
.object({
amount: z.number().int().min(1),
unit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']),
})
.optional(),
});
export type ExpirySettings = z.infer<typeof ZExpirySettingsSchema>;
export interface ExpirySettingsPickerProps {
className?: string;
defaultValues?: Partial<ExpirySettings>;
disabled?: boolean;
onValueChange?: (value: ExpirySettings) => void;
value?: ExpirySettings;
}
export const ExpirySettingsPicker = ({
className,
defaultValues = {
expiryDuration: undefined,
},
disabled = false,
onValueChange,
value,
}: ExpirySettingsPickerProps) => {
const form = useForm<ExpirySettings>({
resolver: zodResolver(ZExpirySettingsSchema),
defaultValues,
mode: 'onChange',
});
const { watch, setValue, getValues } = form;
const expiryDuration = watch('expiryDuration');
const calculatedExpiryDate = React.useMemo(() => {
if (expiryDuration?.amount && expiryDuration?.unit) {
return calculateExpiryDate(expiryDuration);
}
return null;
}, [expiryDuration]);
// Call onValueChange when form values change
React.useEffect(() => {
const subscription = watch((value) => {
if (onValueChange) {
onValueChange(value as ExpirySettings);
}
});
return () => subscription.unsubscribe();
}, [watch, onValueChange]);
// Keep internal form state in sync when a controlled value is provided
React.useEffect(() => {
if (value === undefined) return;
const current = getValues('expiryDuration');
const next = value.expiryDuration;
const amountsDiffer = (current?.amount ?? null) !== (next?.amount ?? null);
const unitsDiffer = (current?.unit ?? null) !== (next?.unit ?? null);
if (amountsDiffer || unitsDiffer) {
setValue('expiryDuration', next, {
shouldDirty: false,
shouldTouch: false,
shouldValidate: false,
});
}
}, [value, getValues, setValue]);
return (
<div className={cn('space-y-4', className)}>
<Form {...form}>
<FormField
control={form.control}
name="expiryDuration"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Link Expiry</Trans>
</FormLabel>
<FormDescription>
<Trans>Set an expiry duration for signing links (leave empty to disable)</Trans>
</FormDescription>
<FormControl>
<DurationSelector
value={field.value}
onChange={field.onChange}
disabled={disabled}
minAmount={1}
maxAmount={365}
/>
</FormControl>
{calculatedExpiryDate && (
<FormDescription>
<Trans>Links will expire on: {formatExpiryDate(calculatedExpiryDate)}</Trans>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</Form>
</div>
);
};