Compare commits

..

10 Commits

141 changed files with 2442 additions and 2290 deletions

View File

@ -13,10 +13,6 @@ NEXT_PRIVATE_ENCRYPTION_SECONDARY_KEY="DEADBEEF"
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#google-oauth-gmail
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
# Find documentation on setting up Microsoft OAuth here:
# https://docs.documenso.com/developers/self-hosting/setting-up-oauth-providers#microsoft-oauth-azure-ad
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=""
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=""
NEXT_PRIVATE_OIDC_WELL_KNOWN=""
NEXT_PRIVATE_OIDC_CLIENT_ID=""

View File

@ -27,33 +27,3 @@ NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=<your-client-secret>
```
Finally verify the signing in with Google works by signing in with your Google account and checking the email address in your profile.
## Microsoft OAuth (Azure AD)
To use Microsoft OAuth, you will need to create an Azure AD application registration in the Microsoft Azure portal. This will allow users to sign in with their Microsoft accounts.
### Create and configure a new Azure AD application
1. Go to the [Azure Portal](https://portal.azure.com/)
2. Navigate to **Azure Active Directory** (or **Microsoft Entra ID** in newer Azure portals)
3. In the left sidebar, click **App registrations**
4. Click **New registration**
5. Enter a name for your application (e.g., "Documenso")
6. Under **Supported account types**, select **Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)** to allow any Microsoft account to sign in
7. Under **Redirect URI**, select **Web** and enter: `https://<documenso-domain>/api/auth/callback/microsoft`
8. Click **Register**
### Configure the application
1. After registration, you'll be taken to the app's overview page
2. Copy the **Application (client) ID** - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_ID`
3. In the left sidebar, click **Certificates & secrets**
4. Under **Client secrets**, click **New client secret**
5. Add a description and select an expiration period
6. Click **Add** and copy the **Value** (not the Secret ID) - this will be your `NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET`
7. In the Documenso environment variables, set the following:
```
NEXT_PRIVATE_MICROSOFT_CLIENT_ID=<your-application-client-id>
NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET=<your-client-secret-value>
```

View File

@ -71,7 +71,7 @@ export const DocumentMoveToFolderDialog = ({
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId,
type: FolderType.DOCUMENT,

View File

@ -63,7 +63,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
const onFormSubmit = async () => {
try {
await deleteFolder({
folderId: folder.id,
id: folder.id,
});
onOpenChange(false);

View File

@ -53,7 +53,7 @@ export const FolderMoveDialog = ({
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
const { mutateAsync: moveFolder } = trpc.folder.updateFolder.useMutation();
const { mutateAsync: moveFolder } = trpc.folder.moveFolder.useMutation();
const form = useForm<TMoveFolderFormSchema>({
resolver: zodResolver(ZMoveFolderFormSchema),
@ -63,16 +63,12 @@ export const FolderMoveDialog = ({
});
const onFormSubmit = async ({ targetFolderId }: TMoveFolderFormSchema) => {
if (!folder) {
return;
}
if (!folder) return;
try {
await moveFolder({
folderId: folder.id,
data: {
parentId: targetFolderId || null,
},
id: folder.id,
parentId: targetFolderId || null,
});
onOpenChange(false);

View File

@ -61,6 +61,8 @@ 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: {
@ -85,11 +87,11 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
try {
await updateFolder({
folderId: folder.id,
data: {
name: data.name,
visibility: data.visibility,
},
id: folder.id,
name: data.name,
visibility: isTeamContext
? (data.visibility ?? DocumentVisibility.EVERYONE)
: DocumentVisibility.EVERYONE,
});
toast({
@ -138,36 +140,38 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
)}
/>
<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>
)}
/>
{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>
)}
/>
)}
<DialogFooter>
<DialogClose asChild>

View File

@ -185,10 +185,6 @@ export const OrganisationMemberInviteDialog = ({
return 'form';
}
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
return 'form';
}
// This is probably going to screw us over in the future.
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
return 'alert';

View File

@ -73,7 +73,7 @@ export function TemplateMoveToFolderDialog({
},
});
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery(
const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFolders.useQuery(
{
parentId: currentFolderId ?? null,
type: FolderType.TEMPLATE,

View File

@ -9,7 +9,6 @@ export type EmbedAuthenticationRequiredProps = {
email?: string;
returnTo: string;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
};
@ -18,7 +17,6 @@ export const EmbedAuthenticationRequired = ({
email,
returnTo,
// isGoogleSSOEnabled,
// isMicrosoftSSOEnabled,
// isOIDCSSOEnabled,
// oidcProviderLabel,
}: EmbedAuthenticationRequiredProps) => {
@ -39,7 +37,6 @@ export const EmbedAuthenticationRequired = ({
<SignInForm
// Embed currently not supported.
// isGoogleSSOEnabled={isGoogleSSOEnabled}
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
// isOIDCSSOEnabled={isOIDCSSOEnabled}
// oidcProviderLabel={oidcProviderLabel}
className="mt-4"

View File

@ -37,7 +37,6 @@ import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-sc
import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { EmbedClientLoading } from './embed-client-loading';
import { EmbedDocumentCompleted } from './embed-document-completed';
@ -45,7 +44,6 @@ import { EmbedDocumentFields } from './embed-document-fields';
export type EmbedDirectTemplateClientPageProps = {
token: string;
envelopeId: string;
updatedAt: Date;
documentData: DocumentData;
recipient: Recipient;
@ -57,10 +55,9 @@ export type EmbedDirectTemplateClientPageProps = {
export const EmbedDirectTemplateClientPage = ({
token,
envelopeId,
updatedAt,
documentData,
recipient,
recipient: _recipient,
fields,
metadata,
hidePoweredBy = false,
@ -324,13 +321,9 @@ export const EmbedDirectTemplateClientPage = ({
}
return (
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={recipient.token} />
</div>
<div className="relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}
<div className="flex-1">

View File

@ -37,7 +37,6 @@ import { BrandingLogo } from '~/components/general/branding-logo';
import { injectCss } from '~/utils/css-vars';
import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema';
import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover';
import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
@ -49,7 +48,6 @@ import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = {
token: string;
documentId: number;
envelopeId: string;
documentData: DocumentData;
recipient: RecipientWithFields;
fields: Field[];
@ -64,7 +62,6 @@ export type EmbedSignDocumentClientPageProps = {
export const EmbedSignDocumentClientPage = ({
token,
documentId,
envelopeId,
documentData,
recipient,
fields,
@ -277,17 +274,15 @@ export const EmbedSignDocumentClientPage = ({
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
{allowDocumentRejection && (
{allowDocumentRejection && (
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
documentId={documentId}
token={token}
onRejected={onDocumentRejected}
/>
)}
</div>
</div>
)}
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
{/* Viewer */}

View File

@ -70,7 +70,6 @@ export type SignInFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
returnTo?: string;
@ -80,7 +79,6 @@ export const SignInForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
@ -92,14 +90,11 @@ export const SignInForm = ({
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
>('totp');
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const redirectPath = useMemo(() => {
@ -276,22 +271,6 @@ export const SignInForm = ({
}
};
const onSignInWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn({
redirectPath,
});
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you In. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onSignInWithOIDCClick = async () => {
try {
await authClient.oidc.signIn({
@ -318,8 +297,6 @@ export const SignInForm = ({
if (email) {
form.setValue('email', email);
}
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, [form]);
return (
@ -386,64 +363,42 @@ export const SignInForm = ({
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{!isEmbeddedRedirect && (
<>
{hasSocialAuthEnabled && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or continue with</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
)}
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or continue with</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
)}
{isGoogleSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
)}
{isGoogleSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
)}
{isMicrosoftSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
Microsoft
</Button>
)}
{isOIDCSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
{oidcProviderLabel || 'OIDC'}
</Button>
)}
</>
{isOIDCSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
{oidcProviderLabel || 'OIDC'}
</Button>
)}
<Button

View File

@ -66,18 +66,14 @@ export type SignUpFormProps = {
className?: string;
initialEmail?: string;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
returnTo?: string;
};
export const SignUpForm = ({
className,
initialEmail,
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
returnTo,
}: SignUpFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -88,8 +84,6 @@ export const SignUpForm = ({
const utmSrc = searchParams.get('utm_source') ?? null;
const hasSocialAuthEnabled = isGoogleSSOEnabled || isMicrosoftSSOEnabled || isOIDCSSOEnabled;
const form = useForm<TSignUpFormSchema>({
values: {
name: '',
@ -112,7 +106,7 @@ export const SignUpForm = ({
signature,
});
await navigate(returnTo ? returnTo : '/unverified-account');
await navigate(`/unverified-account`);
toast({
title: _(msg`Registration Successful`),
@ -154,20 +148,6 @@ export const SignUpForm = ({
}
};
const onSignUpWithMicrosoftClick = async () => {
try {
await authClient.microsoft.signIn();
} catch (err) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to sign you Up. Please try again later.`,
),
variant: 'destructive',
});
}
};
const onSignUpWithOIDCClick = async () => {
try {
await authClient.oidc.signIn();
@ -247,7 +227,7 @@ export const SignUpForm = ({
<fieldset
className={cn(
'flex h-[550px] w-full flex-col gap-y-4',
hasSocialAuthEnabled && 'h-[650px]',
(isGoogleSSOEnabled || isOIDCSSOEnabled) && 'h-[650px]',
)}
disabled={isSubmitting}
>
@ -322,7 +302,7 @@ export const SignUpForm = ({
)}
/>
{hasSocialAuthEnabled && (
{(isGoogleSSOEnabled || isOIDCSSOEnabled) && (
<>
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
@ -350,26 +330,6 @@ export const SignUpForm = ({
</>
)}
{isMicrosoftSSOEnabled && (
<>
<Button
type="button"
size="lg"
variant={'outline'}
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignUpWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
<Trans>Sign Up with Microsoft</Trans>
</Button>
</>
)}
{isOIDCSSOEnabled && (
<>
<Button

View File

@ -1,79 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { ExternalLink, PaperclipIcon } from 'lucide-react';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
export type DocumentSigningAttachmentsPopoverProps = {
envelopeId: string;
token: string;
};
export const DocumentSigningAttachmentsPopover = ({
envelopeId,
token,
}: DocumentSigningAttachmentsPopoverProps) => {
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
token,
});
if (!attachments || attachments.data.length === 0) {
return null;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<PaperclipIcon className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>{' '}
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="start">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Documents and resources related to this envelope.</Trans>
</p>
</div>
<div className="space-y-2">
{attachments?.data.map((attachment) => (
<a
key={attachment.id}
href={attachment.data}
title={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="border-border hover:bg-muted/50 group flex items-center justify-between rounded-md border px-3 py-2.5 transition duration-200"
>
<div className="flex flex-1 items-center gap-2.5">
<div className="bg-muted rounded p-2">
<PaperclipIcon className="h-4 w-4" />
</div>
<span className="text-muted-foreground hover:text-foreground block truncate text-sm underline">
{attachment.label}
</span>
</div>
<ExternalLink className="h-4 w-4 opacity-0 transition duration-200 group-hover:opacity-100" />
</a>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
actionVerb = 'sign',
onOpenChange,
}: DocumentSigningAuthAccountProps) => {
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
const { recipient } = useRequiredDocumentSigningAuthContext();
const { t } = useLingui();
@ -34,10 +34,8 @@ export const DocumentSigningAuthAccount = ({
try {
setIsSigningOut(true);
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
await authClient.signOut({
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
redirectPath: `/signin#email=${email}`,
});
} catch {
setIsSigningOut(false);
@ -57,28 +55,16 @@ export const DocumentSigningAuthAccount = ({
<AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span>
{isDirectTemplate ? (
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
) : (
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
)}
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
</span>
) : (
<span>
{isDirectTemplate ? (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in.
</Trans>
) : (
<Trans>
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
logged in as <strong>{recipient.email}</strong>
</Trans>
)}
{/* Todo: Translate */}
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
in as <strong>{recipient.email}</strong>
</span>
)}
</AlertDescription>

View File

@ -47,8 +47,7 @@ export const DocumentSigningAuthDialog = ({
onOpenChange,
onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
useRequiredDocumentSigningAuthContext();
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter(
@ -169,11 +168,7 @@ export const DocumentSigningAuthDialog = ({
match({ documentAuthType: selectedAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{
user: P.when(
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
),
}, // Assume all current auth methods requires them to be logged in.
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (

View File

@ -37,7 +37,6 @@ export type DocumentSigningAuthContextValue = {
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
isAuthRedirectRequired: boolean;
isDirectTemplate?: boolean;
isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void;
passkeyData: PasskeyData;
@ -66,7 +65,6 @@ export const useRequiredDocumentSigningAuthContext = () => {
export interface DocumentSigningAuthProviderProps {
documentAuthOptions: Envelope['authOptions'];
recipient: SigningAuthRecipient;
isDirectTemplate?: boolean;
user?: SessionUser | null;
children: React.ReactNode;
}
@ -74,7 +72,6 @@ export interface DocumentSigningAuthProviderProps {
export const DocumentSigningAuthProvider = ({
documentAuthOptions: initialDocumentAuthOptions,
recipient: initialRecipient,
isDirectTemplate = false,
user,
children,
}: DocumentSigningAuthProviderProps) => {
@ -204,7 +201,6 @@ export const DocumentSigningAuthProvider = ({
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
isAuthRedirectRequired,
isDirectTemplate,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
passkeyData,

View File

@ -32,7 +32,6 @@ import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover';
import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@ -232,13 +231,7 @@ export const DocumentSigningPageViewV1 = ({
</span>
</div>
<div className="flex items-center gap-x-4">
<DocumentSigningAttachmentsPopover
envelopeId={document.envelopeId}
token={recipient.token}
/>
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
</div>
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
</div>
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">

View File

@ -19,7 +19,6 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
import { EnvelopeSignerHeader } from '../envelope-signing/envelope-signer-header';
@ -32,13 +31,8 @@ const EnvelopeSignerPageRenderer = lazy(
export const DocumentSigningPageViewV2 = () => {
const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const {
envelope,
recipient,
recipientFields,
recipientFieldsRemaining,
showPendingFieldTooltip,
} = useRequiredEnvelopeSigningContext();
const { envelope, recipientFields, recipientFieldsRemaining, showPendingFieldTooltip } =
useRequiredEnvelopeSigningContext();
return (
<div className="h-screen w-screen bg-gray-50">
@ -89,10 +83,6 @@ export const DocumentSigningPageViewV2 = () => {
<Trans>Actions</Trans>
</h4>
<div className="w-full">
<DocumentSigningAttachmentsPopover envelopeId={envelope.id} token={recipient.token} />
</div>
{/* Todo: Allow selecting which document to download and/or the original */}
<Button variant="ghost" size="sm" className="w-full justify-start">
<DownloadCloudIcon className="mr-2 h-4 w-4" />

View File

@ -1,241 +0,0 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Paperclip, Plus, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
};
const ZAttachmentFormSchema = z.object({
label: z.string().min(1, 'Label is required'),
url: z.string().url('Must be a valid URL'),
});
type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const DocumentAttachmentsPopover = ({ envelopeId }: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const [isOpen, setIsOpen] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const utils = trpc.useUtils();
const { data: attachments } = trpc.envelope.attachment.find.useQuery({
envelopeId,
});
const { mutateAsync: createAttachment, isPending: isCreating } =
trpc.envelope.attachment.create.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const { mutateAsync: deleteAttachment } = trpc.envelope.attachment.delete.useMutation({
onSuccess: () => {
void utils.envelope.attachment.find.invalidate({ envelopeId });
},
});
const form = useForm<TAttachmentFormSchema>({
resolver: zodResolver(ZAttachmentFormSchema),
defaultValues: {
label: '',
url: '',
},
});
const onSubmit = async (data: TAttachmentFormSchema) => {
try {
await createAttachment({
envelopeId,
data: {
label: data.label,
data: data.url,
},
});
form.reset();
setIsAdding(false);
toast({
title: _(msg`Success`),
description: _(msg`Attachment added successfully.`),
});
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Error`),
description: error.message,
variant: 'destructive',
});
}
};
const onDeleteAttachment = async (id: string) => {
try {
await deleteAttachment({ id });
toast({
title: _(msg`Success`),
description: _(msg`Attachment removed successfully.`),
});
} catch (err) {
const error = AppError.parseError(err);
toast({
title: _(msg`Error`),
description: error.message,
variant: 'destructive',
});
}
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="gap-2">
<Paperclip className="h-4 w-4" />
<span>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && (
<span className="ml-1">({attachments.data.length})</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<div className="space-y-4">
<div>
<h4 className="font-medium">
<Trans>Attachments</Trans>
</h4>
<p className="text-muted-foreground mt-1 text-sm">
<Trans>Add links to relevant documents or resources.</Trans>
</p>
</div>
{attachments && attachments.data.length > 0 && (
<div className="space-y-2">
{attachments?.data.map((attachment) => (
<div
key={attachment.id}
className="border-border flex items-center justify-between rounded-md border p-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{attachment.label}</p>
<a
href={attachment.data}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground truncate text-xs underline"
>
{attachment.data}
</a>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void onDeleteAttachment(attachment.id)}
className="ml-2 h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{!isAdding && (
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsAdding(true)}
>
<Plus className="mr-2 h-4 w-4" />
<Trans>Add Attachment</Trans>
</Button>
)}
{isAdding && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<FormField
control={form.control}
name="label"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder={_(msg`Label`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input type="url" placeholder={_(msg`URL`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button type="submit" size="sm" className="flex-1" loading={isCreating}>
<Trans>Add</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
setIsAdding(false);
form.reset();
}}
>
<Trans>Cancel</Trans>
</Button>
</div>
</form>
</Form>
)}
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -223,6 +223,8 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@ -247,6 +249,8 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@ -476,6 +480,17 @@ 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}

View File

@ -156,6 +156,14 @@ 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 && (

View File

@ -22,7 +22,6 @@ import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribu
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { TemplateUseDialog } from '~/components/dialogs/template-use-dialog';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { useCurrentTeam } from '~/providers/team';
@ -132,8 +131,6 @@ export default function EnvelopeEditorHeader() {
</div>
<div className="flex items-center space-x-2">
<DocumentAttachmentsPopover envelopeId={envelope.id} />
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="outline" size="sm">

View File

@ -12,7 +12,6 @@ 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';
@ -29,15 +28,22 @@ 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, onSettings, onDelete }: FolderCardProps) => {
export const FolderCard = ({
folder,
onMove,
onPin,
onUnpin,
onSettings,
onDelete,
}: FolderCardProps) => {
const team = useCurrentTeam();
const { mutateAsync: updateFolderMutation } = trpc.folder.updateFolder.useMutation();
const formatPath = () => {
const rootPath =
folder.type === FolderType.DOCUMENT
@ -47,15 +53,6 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
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">
@ -115,7 +112,9 @@ export const FolderCard = ({ folder, onMove, onSettings, onDelete }: FolderCardP
<Trans>Move</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => updateFolder({ pinned: !folder.pinned })}>
<DropdownMenuItem
onClick={() => (folder.pinned ? onUnpin(folder.id) : onPin(folder.id))}
>
<PinIcon className="mr-2 h-4 w-4" />
{folder.pinned ? <Trans>Unpin</Trans> : <Trans>Pin</Trans>}
</DropdownMenuItem>

View File

@ -34,6 +34,9 @@ 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,
@ -152,6 +155,8 @@ 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);
@ -175,6 +180,8 @@ 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);

View File

@ -41,6 +41,9 @@ 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;
}

View File

@ -48,13 +48,20 @@ 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,
(recipient) =>
getRecipientType(recipient) !== RecipientStatusType.REJECTED &&
getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
);
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;
}),
@ -117,6 +124,30 @@ 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">

View File

@ -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, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@ -36,6 +36,7 @@ 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;
@ -87,8 +88,15 @@ 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 },
() => (

View File

@ -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, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
@ -193,6 +193,7 @@ 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) {
@ -230,7 +231,14 @@ 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}`}>

View File

@ -9,7 +9,6 @@ import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { canAccessTeamDocument, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
import { DocumentStatus } from '~/components/general/document/document-status';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
@ -123,13 +122,11 @@ export default function DocumentEditPage() {
</div>
</div>
<div className="flex items-center gap-x-4">
<DocumentAttachmentsPopover envelopeId={document.envelopeId} />
{document.useLegacyFieldInsertion && (
{document.useLegacyFieldInsertion && (
<div>
<LegacyFieldWarningPopover type="document" documentId={document.id} />
)}
</div>
</div>
)}
</div>
<DocumentEditForm

View File

@ -42,6 +42,9 @@ 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);
@ -110,6 +113,8 @@ export default function DocumentsFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@ -142,6 +147,8 @@ export default function DocumentsFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);

View File

@ -8,7 +8,6 @@ import { getTemplateById } from '@documenso/lib/server-only/template/get-templat
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { DocumentAttachmentsPopover } from '~/components/general/document/document-attachments-popover';
import { LegacyFieldWarningPopover } from '~/components/general/legacy-field-warning-popover';
import { TemplateDirectLinkBadge } from '~/components/general/template/template-direct-link-badge';
import { TemplateEditForm } from '~/components/general/template/template-edit-form';
@ -88,8 +87,6 @@ export default function TemplateEditPage() {
</div>
<div className="mt-2 flex items-center gap-2 sm:mt-0 sm:self-end">
<DocumentAttachmentsPopover envelopeId={template.envelopeId} />
<TemplateDirectLinkDialog
templateId={template.id}
directLink={template.directLink}

View File

@ -42,6 +42,9 @@ 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);
@ -110,6 +113,8 @@ export default function TemplatesFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);
@ -142,6 +147,8 @@ export default function TemplatesFoldersPage() {
setFolderToMove(folder);
setIsMovingFolder(true);
}}
onPin={(folderId) => void pinFolder({ folderId })}
onUnpin={(folderId) => void unpinFolder({ folderId })}
onSettings={(folder) => {
setFolderToSettings(folder);
setIsSettingsFolderOpen(true);

View File

@ -95,7 +95,6 @@ export default function DirectTemplatePage() {
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
isDirectTemplate={true}
user={user}
>
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">

View File

@ -16,6 +16,7 @@ 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';
@ -25,6 +26,7 @@ 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';
@ -136,6 +138,13 @@ 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`);
}
@ -239,6 +248,13 @@ 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`);
}

View File

@ -0,0 +1,141 @@
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>
);
}

View File

@ -1,17 +1,13 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { SignInForm } from '~/components/forms/signin';
import { appMetaTags } from '~/utils/meta';
@ -27,45 +23,22 @@ export async function loader({ request }: Route.LoaderArgs) {
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
if (isAuthenticated) {
throw redirect(returnTo || '/');
throw redirect('/');
}
return {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
} = loaderData;
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, []);
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData;
return (
<div className="w-screen max-w-lg px-4">
@ -81,20 +54,15 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
<SignInForm
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
returnTo={returnTo}
/>
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
<p className="text-muted-foreground mt-6 text-center text-sm">
<Trans>
Don't have an account?{' '}
<Link
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
className="text-documenso-700 duration-200 hover:opacity-70"
>
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
Sign up
</Link>
</Trans>

View File

@ -1,12 +1,7 @@
import { redirect } from 'react-router';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
} from '@documenso/lib/constants/auth';
import { IS_GOOGLE_SSO_ENABLED, IS_OIDC_SSO_ENABLED } from '@documenso/lib/constants/auth';
import { env } from '@documenso/lib/utils/env';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { SignUpForm } from '~/components/forms/signup';
import { appMetaTags } from '~/utils/meta';
@ -17,40 +12,31 @@ export function meta() {
return appMetaTags('Sign Up');
}
export function loader({ request }: Route.LoaderArgs) {
export function loader() {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
if (NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
throw redirect('/signin');
}
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
return {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
returnTo,
};
}
export default function SignUp({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
const { isGoogleSSOEnabled, isOIDCSSOEnabled } = loaderData;
return (
<SignUpForm
className="w-screen max-w-screen-2xl px-4 md:px-16 lg:-my-16"
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
returnTo={returnTo}
/>
);
}

View File

@ -2,7 +2,6 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
@ -30,13 +29,11 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
export function loader() {
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
return {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
};
@ -47,8 +44,7 @@ export default function Layout() {
}
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData || {};
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
const error = useRouteError();
@ -57,7 +53,6 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
return (
<EmbedAuthenticationRequired
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
email={error.data.email}

View File

@ -69,6 +69,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw data(
{
type: 'embed-authentication-required',
email: user?.email,
returnTo: `/embed/direct/${token}`,
},
{
@ -116,13 +117,11 @@ export default function EmbedDirectTemplatePage() {
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
recipient={recipient}
isDirectTemplate={true}
user={user}
>
<DocumentSigningRecipientProvider recipient={recipient}>
<EmbedDirectTemplateClientPage
token={token}
envelopeId={template.envelopeId}
updatedAt={template.updatedAt}
documentData={template.templateDocumentData}
recipient={recipient}

View File

@ -164,7 +164,6 @@ export default function EmbedSignDocumentPage() {
<EmbedSignDocumentClientPage
token={token}
documentId={document.id}
envelopeId={document.envelopeId}
documentData={document.documentData}
recipient={recipient}
fields={fields}

View File

@ -103,5 +103,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.13.2"
"version": "1.12.10"
}

View File

@ -1 +0,0 @@
<svg viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" width="256" height="256" preserveAspectRatio="xMidYMid"><path fill="#F1511B" d="M121.666 121.666H0V0h121.666z"/><path fill="#80CC28" d="M256 121.666H134.335V0H256z"/><path fill="#00ADEF" d="M121.663 256.002H0V134.336h121.663z"/><path fill="#FBBC09" d="M256 256.002H134.335V134.336H256z"/></svg>

Before

Width:  |  Height:  |  Size: 356 B

6
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.13.2",
"version": "1.12.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.13.2",
"version": "1.12.10",
"workspaces": [
"apps/*",
"packages/*"
@ -89,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.13.2",
"version": "1.12.10",
"dependencies": {
"@cantoo/pdf-lib": "^2.3.2",
"@documenso/api": "*",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.13.2",
"version": "1.12.10",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",

View File

@ -427,7 +427,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
globalAccessAuth: body.authOptions?.globalAccessAuth,
globalActionAuth: body.authOptions?.globalActionAuth,
},
attachments: body.attachments,
meta: {
subject: body.meta.subject,
message: body.meta.message,
@ -498,7 +497,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription,
type,
meta,
attachments,
} = body;
try {
@ -570,7 +568,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
publicDescription,
},
meta,
attachments,
requestMetadata: metadata,
});
@ -795,7 +792,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
...body.meta,
title: body.title,
},
attachments: body.attachments,
requestMetadata: metadata,
});

View File

@ -22,7 +22,6 @@ import {
ZRecipientActionAuthTypesSchema,
} from '@documenso/lib/types/document-auth';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';
import { ZFieldMetaPrefillFieldsSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
extendZodWithOpenApi(z);
@ -198,15 +197,6 @@ export const ZCreateDocumentMutationSchema = z.object({
description: 'The globalActionAuth property is only available for Enterprise accounts.',
}),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
});
export type TCreateDocumentMutationSchema = z.infer<typeof ZCreateDocumentMutationSchema>;
@ -272,15 +262,6 @@ export const ZCreateDocumentFromTemplateMutationSchema = z.object({
})
.optional(),
formValues: z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])).optional(),
attachments: z
.array(
z.object({
label: z.string().min(1, 'Label is required'),
data: z.string().url('Must be a valid URL'),
type: ZEnvelopeAttachmentTypeSchema.optional().default('link'),
}),
)
.optional(),
});
export type TCreateDocumentFromTemplateMutationSchema = z.infer<

View File

@ -24,7 +24,6 @@ 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';
@ -327,6 +326,11 @@ 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);
});
@ -403,6 +407,11 @@ 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);
});
@ -2706,154 +2715,4 @@ 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);
});
});
});

View File

@ -222,22 +222,6 @@ export class AuthClient {
},
};
public microsoft = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.microsoft.$post({
json: { redirectPath },
});
await this.handleError(response);
const data = await response.json();
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
}
},
};
public oidc = {
signIn: async ({ redirectPath }: { redirectPath?: string } = {}) => {
const response = await this.client['oauth'].authorize.oidc.$post({ json: { redirectPath } });

View File

@ -26,16 +26,6 @@ export const GoogleAuthOptions: OAuthClientOptions = {
bypassEmailVerification: false,
};
export const MicrosoftAuthOptions: OAuthClientOptions = {
id: 'microsoft',
scope: ['openid', 'email', 'profile'],
clientId: env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') ?? '',
clientSecret: env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET') ?? '',
redirectUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/callback/microsoft`,
wellKnownUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
bypassEmailVerification: false,
};
export const OidcAuthOptions: OAuthClientOptions = {
id: 'oidc',
scope: ['openid', 'email', 'profile'],

View File

@ -5,7 +5,6 @@ import { deleteCookie } from 'hono/cookie';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { prisma } from '@documenso/prisma';
import type { OAuthClientOptions } from '../../config';
@ -178,12 +177,6 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
redirectPath = '/';
}
if (!isValidReturnTo(redirectPath)) {
redirectPath = '/';
}
redirectPath = normalizeReturnTo(redirectPath) || '/';
const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint,
code,

View File

@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { AppError } from '@documenso/lib/errors/app-error';
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthCallbackUrl } from '../lib/utils/handle-oauth-callback-url';
import { handleOAuthOrganisationCallbackUrl } from '../lib/utils/handle-oauth-organisation-callback-url';
import type { HonoAuthContext } from '../types/context';
@ -45,11 +45,4 @@ export const callbackRoute = new Hono<HonoAuthContext>()
/**
* Google callback verification.
*/
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }))
/**
* Microsoft callback verification.
*/
.get('/microsoft', async (c) =>
handleOAuthCallbackUrl({ c, clientOptions: MicrosoftAuthOptions }),
);
.get('/google', async (c) => handleOAuthCallbackUrl({ c, clientOptions: GoogleAuthOptions }));

View File

@ -2,7 +2,7 @@ import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { GoogleAuthOptions, MicrosoftAuthOptions, OidcAuthOptions } from '../config';
import { GoogleAuthOptions, OidcAuthOptions } from '../config';
import { handleOAuthAuthorizeUrl } from '../lib/utils/handle-oauth-authorize-url';
import { getOrganisationAuthenticationPortalOptions } from '../lib/utils/organisation-portal';
import type { HonoAuthContext } from '../types/context';
@ -24,20 +24,6 @@ export const oauthRoute = new Hono<HonoAuthContext>()
redirectPath,
});
})
/**
* Microsoft authorize endpoint.
*/
.post('/authorize/microsoft', sValidator('json', ZOAuthAuthorizeSchema), async (c) => {
const { redirectPath } = c.req.valid('json');
return handleOAuthAuthorizeUrl({
c,
clientOptions: MicrosoftAuthOptions,
redirectPath,
});
})
/**
* OIDC authorize endpoint.
*/

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { RefObject, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.

View File

@ -13,6 +13,7 @@ export enum RecipientStatusType {
WAITING = 'waiting',
UNSIGNED = 'unsigned',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export const getRecipientType = (
@ -27,6 +28,10 @@ export const getRecipientType = (
return RecipientStatusType.REJECTED;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return RecipientStatusType.EXPIRED;
}
if (
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
@ -52,6 +57,10 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
return RecipientStatusType.UNSIGNED;
}
if (types.includes(RecipientStatusType.EXPIRED)) {
return RecipientStatusType.EXPIRED;
}
if (types.includes(RecipientStatusType.OPENED)) {
return RecipientStatusType.OPENED;
}

View File

@ -6,7 +6,6 @@ export const SALT_ROUNDS = 12;
export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso',
GOOGLE: 'Google',
MICROSOFT: 'Microsoft',
OIDC: 'OIDC',
};
@ -14,10 +13,6 @@ export const IS_GOOGLE_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_GOOGLE_CLIENT_ID') && env('NEXT_PRIVATE_GOOGLE_CLIENT_SECRET'),
);
export const IS_MICROSOFT_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_MICROSOFT_CLIENT_ID') && env('NEXT_PRIVATE_MICROSOFT_CLIENT_SECRET'),
);
export const IS_OIDC_SSO_ENABLED = Boolean(
env('NEXT_PRIVATE_OIDC_WELL_KNOWN') &&
env('NEXT_PRIVATE_OIDC_CLIENT_ID') &&

View File

@ -15,6 +15,7 @@ export const getRecipientsStats = async () => {
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SigningStatus.EXPIRED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};

View File

@ -10,6 +10,7 @@ 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';
@ -37,6 +38,8 @@ export type CreateDocumentMetaOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
expiryAmount?: number;
expiryUnit?: string;
requestMetadata: ApiRequestMetadata;
};
@ -59,6 +62,8 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
@ -120,9 +125,30 @@ 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.

View File

@ -0,0 +1,292 @@
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;
});
};

View File

@ -0,0 +1,177 @@
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;
});
};

View File

@ -26,6 +26,7 @@ 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';
@ -210,6 +211,39 @@ 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,

View File

@ -81,15 +81,11 @@ export const sendCompletedEmail = async ({ id, requestMetadata }: SendDocumentOp
const { user: owner } = envelope;
const completedDocumentEmailAttachments = await Promise.all(
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';
envelope.envelopeItems.map(async (document) => {
const file = await getFileServerSide(document.documentData);
return {
filename: fileNameToUse.endsWith('.pdf') ? fileNameToUse : fileNameToUse + '.pdf',
fileName: document.title.endsWith('.pdf') ? document.title : document.title + '.pdf',
content: Buffer.from(file),
contentType: 'application/pdf',
};

View File

@ -24,6 +24,7 @@ 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';
@ -177,6 +178,24 @@ 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,

View File

@ -1,50 +0,0 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type CreateAttachmentOptions = {
envelopeId: string;
teamId: number;
userId: number;
data: {
label: string;
data: string;
};
};
export const createAttachment = async ({
envelopeId,
teamId,
userId,
data,
}: CreateAttachmentOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.status === DocumentStatus.COMPLETED || envelope.status === DocumentStatus.REJECTED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
return await prisma.envelopeAttachment.create({
data: {
envelopeId,
type: 'link',
...data,
},
});
};

View File

@ -1,47 +0,0 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type DeleteAttachmentOptions = {
id: string;
userId: number;
teamId: number;
};
export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentOptions) => {
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
},
});
if (!attachment) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
await prisma.envelopeAttachment.delete({
where: {
id,
},
});
};

View File

@ -1,38 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type FindAttachmentsByEnvelopeIdOptions = {
envelopeId: string;
userId: number;
teamId: number;
};
export const findAttachmentsByEnvelopeId = async ({
envelopeId,
userId,
teamId,
}: FindAttachmentsByEnvelopeIdOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};

View File

@ -1,70 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
export type FindAttachmentsByTokenOptions = {
envelopeId: string;
token: string;
};
export const findAttachmentsByToken = async ({
envelopeId,
token,
}: FindAttachmentsByTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
recipients: {
some: {
token,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};
export type FindAttachmentsByTeamOptions = {
envelopeId: string;
teamId: number;
};
export const findAttachmentsByTeam = async ({
envelopeId,
teamId,
}: FindAttachmentsByTeamOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
teamId,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
return await prisma.envelopeAttachment.findMany({
where: {
envelopeId,
},
orderBy: {
createdAt: 'asc',
},
});
};

View File

@ -1,49 +0,0 @@
import { DocumentStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
export type UpdateAttachmentOptions = {
id: string;
userId: number;
teamId: number;
data: { label?: string; data?: string };
};
export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttachmentOptions) => {
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
},
});
if (!attachment) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Attachments can not be modified after the document has been completed or rejected',
});
}
return await prisma.envelopeAttachment.update({
where: {
id,
},
data,
});
};

View File

@ -20,7 +20,6 @@ import type { TCreateEnvelopeRequest } from '@documenso/trpc/server/envelope-rou
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import {
ZWebhookDocumentSchema,
mapEnvelopeToWebhookDocumentPayload,
@ -59,11 +58,6 @@ export type CreateEnvelopeOptions = {
recipients?: TCreateEnvelopeRequest['recipients'];
folderId?: string;
};
attachments?: Array<{
label: string;
data: string;
type?: TEnvelopeAttachmentType;
}>;
meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata;
};
@ -73,7 +67,6 @@ export const createEnvelope = async ({
teamId,
normalizePdf,
data,
attachments,
meta,
requestMetadata,
internalVersion,
@ -253,15 +246,6 @@ export const createEnvelope = async ({
})),
},
},
envelopeAttachments: {
createMany: {
data: (attachments || []).map((attachment) => ({
label: attachment.label,
data: attachment.data,
type: attachment.type ?? 'link',
})),
},
},
userId,
teamId,
authOptions,
@ -354,7 +338,6 @@ export const createEnvelope = async ({
fields: true,
folder: true,
envelopeItems: true,
envelopeAttachments: true,
},
});

View File

@ -25,7 +25,9 @@ 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;
@ -115,6 +117,11 @@ 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`);
}

View File

@ -1,9 +1,7 @@
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 {
@ -24,27 +22,6 @@ 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,

View File

@ -1,7 +1,6 @@
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';
@ -21,9 +20,6 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
teamId,
userId,
}),
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
},
});
@ -43,7 +39,7 @@ export const deleteFolder = async ({ userId, teamId, folderId }: DeleteFolderOpt
return await prisma.folder.delete({
where: {
id: folder.id,
id: folderId,
},
});
};

View File

@ -1,117 +0,0 @@
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;
}
};

View File

@ -1,11 +1,9 @@
import type { Prisma } from '@prisma/client';
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 type { FindResultResponse } from '../../types/search-params';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface FindFoldersOptions {
@ -13,48 +11,102 @@ export interface FindFoldersOptions {
teamId: number;
parentId?: string | null;
type?: TFolderType;
page?: number;
perPage?: number;
}
export const findFolders = async ({
userId,
teamId,
parentId,
type,
page = 1,
perPage = 10,
}: FindFoldersOptions) => {
export const findFolders = async ({ userId, teamId, parentId, type }: FindFoldersOptions) => {
const team = await getTeamById({ userId, teamId });
const whereClause: Prisma.FolderWhereInput = {
parentId,
team: buildTeamWhereQuery({ teamId, userId }),
type,
const visibilityFilters = {
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
};
const [data, count] = await Promise.all([
prisma.folder.findMany({
where: whereClause,
skip: Math.max(page - 1, 0) * perPage,
take: perPage,
orderBy: {
createdAt: 'desc',
const whereClause = {
AND: [
{ parentId },
{
OR: [
{ teamId, ...visibilityFilters },
{ userId, teamId },
],
},
}),
prisma.folder.count({
where: whereClause,
}),
]);
],
};
return {
data,
count,
currentPage: Math.max(page, 1),
perPage,
totalPages: Math.ceil(count / perPage),
} satisfies FindResultResponse<typeof data>;
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;
}
};

View File

@ -1,30 +1,51 @@
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 { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import { DocumentVisibility } from '../../types/document-visibility';
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 folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({ teamId, userId }),
type,
const visibilityFilters = match(team.currentTeamRole)
.with(TeamMemberRole.ADMIN, () => ({
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
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,
});
if (!folder) {

View File

@ -0,0 +1,89 @@
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,
},
});
});
};

View File

@ -0,0 +1,40 @@
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,
},
});
};

View File

@ -0,0 +1,40 @@
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,
},
});
};

View File

@ -1,28 +1,28 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import type { DocumentVisibility } from '@documenso/prisma/generated/types';
import { DocumentVisibility } from '@documenso/prisma/generated/types';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { TFolderType } from '../../types/folder-type';
import { FolderType } from '../../types/folder-type';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
export interface UpdateFolderOptions {
userId: number;
teamId: number;
teamId?: number;
folderId: string;
data: {
parentId?: string | null;
name?: string;
visibility?: DocumentVisibility;
pinned?: boolean;
};
name: string;
visibility: DocumentVisibility;
type?: TFolderType;
}
export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFolderOptions) => {
const { parentId, name, visibility, pinned } = data;
const team = await getTeamById({ userId, teamId });
export const updateFolder = async ({
userId,
teamId,
folderId,
name,
visibility,
type,
}: UpdateFolderOptions) => {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
@ -30,9 +30,7 @@ export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFol
teamId,
userId,
}),
visibility: {
in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole],
},
type,
},
});
@ -42,66 +40,17 @@ export const updateFolder = async ({ userId, teamId, folderId, data }: UpdateFol
});
}
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;
}
}
const isTemplateFolder = folder.type === FolderType.TEMPLATE;
const effectiveVisibility =
isTemplateFolder && teamId !== null ? DocumentVisibility.EVERYONE : visibility;
return await prisma.folder.update({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
data: {
name,
visibility,
parentId,
pinned,
visibility: effectiveVisibility,
},
});
};

View File

@ -0,0 +1,36 @@
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,
},
});
};

View File

@ -640,23 +640,6 @@ export const createDocumentFromDirectTemplate = async ({
data: auditLogsToCreate,
});
const templateAttachments = await tx.envelopeAttachment.findMany({
where: {
envelopeId: directTemplateEnvelope.id,
},
});
if (templateAttachments.length > 0) {
await tx.envelopeAttachment.createMany({
data: templateAttachments.map((attachment) => ({
envelopeId: createdEnvelope.id,
type: attachment.type,
label: attachment.label,
data: attachment.data,
})),
});
}
// Send email to template owner.
const emailTemplate = createElement(DocumentCreatedFromDirectTemplateEmailTemplate, {
recipientName: directRecipientEmail,

View File

@ -52,6 +52,7 @@ 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';
@ -91,12 +92,6 @@ export type CreateDocumentFromTemplateOptions = {
envelopeItemId?: string;
}[];
attachments?: Array<{
label: string;
data: string;
type?: 'link';
}>;
/**
* Values that will override the predefined values in the template.
*/
@ -116,6 +111,8 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
expiryAmount?: number;
expiryUnit?: string;
};
requestMetadata: ApiRequestMetadata;
};
@ -301,7 +298,6 @@ export const createDocumentFromTemplate = async ({
requestMetadata,
folderId,
prefillFields,
attachments,
}: CreateDocumentFromTemplateOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
@ -515,6 +511,16 @@ 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,
@ -530,6 +536,7 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
expired: recipientExpiryDate,
token: recipient.token,
};
}),
@ -674,33 +681,6 @@ export const createDocumentFromTemplate = async ({
}),
});
const templateAttachments = await tx.envelopeAttachment.findMany({
where: {
envelopeId: template.id,
},
});
const attachmentsToCreate = [
...templateAttachments.map((attachment) => ({
envelopeId: envelope.id,
type: attachment.type,
label: attachment.label,
data: attachment.data,
})),
...(attachments || []).map((attachment) => ({
envelopeId: envelope.id,
type: attachment.type || 'link',
label: attachment.label,
data: attachment.data,
})),
];
if (attachmentsToCreate.length > 0) {
await tx.envelopeAttachment.createMany({
data: attachmentsToCreate,
});
}
const createdEnvelope = await tx.envelope.findFirst({
where: {
id: envelope.id,

View File

@ -1,23 +1,22 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { kyselyPrisma, sql } from '@documenso/prisma';
export const getCompletedDocumentsMonthly = async () => {
const qb = kyselyPrisma.$kysely
.selectFrom('Envelope')
.selectFrom('Document')
.select(({ fn }) => [
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Envelope.updatedAt']).as('month'),
fn<Date>('DATE_TRUNC', [sql.lit('MONTH'), 'Document.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'), 'Envelope.updatedAt']) as any))
.over((ob) => ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'Document.updatedAt']) as any))
.as('cume_count'),
])
.where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`)
.where(() => sql`"Document"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`)
.groupBy('month')
.orderBy('month', 'desc')
.limit(12);

View File

@ -267,6 +267,11 @@ 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"
@ -1705,6 +1710,10 @@ 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
@ -4076,9 +4085,18 @@ 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}"
@ -4958,6 +4976,11 @@ 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"
@ -4979,6 +5002,11 @@ 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}"
@ -5967,6 +5995,10 @@ 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"
@ -6381,6 +6413,10 @@ 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"
@ -7095,6 +7131,10 @@ 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"
@ -7393,6 +7433,10 @@ 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"
@ -8267,6 +8311,10 @@ 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."
@ -8585,6 +8633,10 @@ 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."

View File

@ -262,6 +262,11 @@ 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"
@ -1700,6 +1705,10 @@ 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
@ -4071,9 +4080,18 @@ 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}"
@ -4953,6 +4971,11 @@ 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"
@ -4974,6 +4997,11 @@ 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}"
@ -5962,6 +5990,10 @@ 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"
@ -6376,6 +6408,10 @@ 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"
@ -7090,6 +7126,10 @@ 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"
@ -7388,6 +7428,10 @@ 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"
@ -8272,6 +8316,10 @@ 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."
@ -8600,6 +8648,10 @@ 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."

View File

@ -267,6 +267,11 @@ 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"
@ -1705,6 +1710,10 @@ 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
@ -4076,9 +4085,18 @@ 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}"
@ -4958,6 +4976,11 @@ 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"
@ -4979,6 +5002,11 @@ 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}"
@ -5967,6 +5995,10 @@ 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"
@ -6381,6 +6413,10 @@ 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"
@ -7095,6 +7131,10 @@ 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"
@ -7393,6 +7433,10 @@ 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"
@ -8267,6 +8311,10 @@ 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."
@ -8587,6 +8635,10 @@ 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."

View File

@ -267,6 +267,11 @@ 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"
@ -1705,6 +1710,10 @@ 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
@ -4076,9 +4085,18 @@ 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}"
@ -4958,6 +4976,11 @@ 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"
@ -4979,6 +5002,11 @@ 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}"
@ -5967,6 +5995,10 @@ 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"
@ -6381,6 +6413,10 @@ 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é"
@ -7095,6 +7131,10 @@ 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"
@ -7393,6 +7433,10 @@ 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"
@ -8267,6 +8311,10 @@ 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."
@ -8585,6 +8633,10 @@ 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."

View File

@ -267,6 +267,11 @@ 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"
@ -1705,6 +1710,10 @@ 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
@ -4076,9 +4085,18 @@ 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}"
@ -4958,6 +4976,11 @@ 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"
@ -4979,6 +5002,11 @@ 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}"
@ -5967,6 +5995,10 @@ 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"
@ -6381,6 +6413,10 @@ 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"
@ -7095,6 +7131,10 @@ 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"
@ -7393,6 +7433,10 @@ 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"
@ -8275,6 +8319,10 @@ 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."
@ -8601,6 +8649,10 @@ 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."

View File

@ -267,6 +267,11 @@ 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"
@ -1705,6 +1710,10 @@ 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
@ -4076,9 +4085,18 @@ 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}"
@ -4958,6 +4976,11 @@ 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"
@ -4979,6 +5002,11 @@ 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}"
@ -5967,6 +5995,10 @@ 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"
@ -6381,6 +6413,10 @@ 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"
@ -7095,6 +7131,10 @@ 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"
@ -7393,6 +7433,10 @@ 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"
@ -8267,6 +8311,10 @@ 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."
@ -8585,6 +8633,10 @@ 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."

View File

@ -40,6 +40,7 @@ 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.
@ -639,6 +640,20 @@ 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(),
@ -680,6 +695,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventRecipientExpiryExtendedSchema,
]),
);

View File

@ -107,6 +107,16 @@ 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
@ -128,6 +138,8 @@ 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>;

View File

@ -69,6 +69,8 @@ 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(),

View File

@ -1,5 +0,0 @@
import { z } from 'zod';
export const ZEnvelopeAttachmentTypeSchema = z.enum(['link']);
export type TEnvelopeAttachmentType = z.infer<typeof ZEnvelopeAttachmentTypeSchema>;

View File

@ -515,6 +515,10 @@ 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 {

View File

@ -20,6 +20,26 @@ 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.
@ -62,6 +82,10 @@ 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'>;
};

View File

@ -0,0 +1,72 @@
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');
};

View File

@ -1,37 +0,0 @@
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
export const isValidReturnTo = (returnTo?: string) => {
if (!returnTo) {
return false;
}
try {
// Decode if it's URL encoded
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
if (returnToUrl.origin !== NEXT_PUBLIC_WEBAPP_URL()) {
return false;
}
return true;
} catch {
return false;
}
};
export const normalizeReturnTo = (returnTo?: string) => {
if (!returnTo) {
return undefined;
}
try {
// Decode if it's URL encoded
const decodedReturnTo = decodeURIComponent(returnTo);
const returnToUrl = new URL(decodedReturnTo, NEXT_PUBLIC_WEBAPP_URL());
return `${returnToUrl.pathname}${returnToUrl.search}${returnToUrl.hash}`;
} catch {
return undefined;
}
};

View File

@ -0,0 +1,6 @@
-- AlterEnum
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
ADD COLUMN "expiryUnit" TEXT;

View File

@ -1,15 +0,0 @@
-- CreateTable
CREATE TABLE "EnvelopeAttachment" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"label" TEXT NOT NULL,
"data" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"envelopeId" TEXT NOT NULL,
CONSTRAINT "EnvelopeAttachment_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "EnvelopeAttachment" ADD CONSTRAINT "EnvelopeAttachment_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -422,8 +422,6 @@ model Envelope {
documentMetaId String @unique
documentMeta DocumentMeta @relation(fields: [documentMetaId], references: [id])
envelopeAttachments EnvelopeAttachment[]
}
model EnvelopeItem {
@ -507,25 +505,12 @@ model DocumentMeta {
emailReplyTo String?
emailId String?
expiryAmount Int?
expiryUnit String?
envelope Envelope?
}
/// @zod.import(["import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment';"])
model EnvelopeAttachment {
id String @id @default(cuid())
type String /// [EnvelopeAttachmentType] @zod.custom.use(ZEnvelopeAttachmentTypeSchema)
label String
data String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
envelopeId String
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
}
enum ReadStatus {
NOT_OPENED
OPENED
@ -540,6 +525,7 @@ enum SigningStatus {
NOT_SIGNED
SIGNED
REJECTED
EXPIRED
}
enum RecipientRole {

Some files were not shown because too many files have changed in this diff Show More