Compare commits

...
Author SHA1 Message Date
Catalin Pit a7102bbd32 Merge branch 'main' into fix/envelope-editor-mobile-view 2026-09-15 10:16:18 +03:00
Lucas Smith e73450dd08 chore: upgrade deps (#3367) 2026-09-14 16:14:44 +10:00
Christopher Ryan 5603a9e59d fix: preserve base path in email asset URLs (#3327) 2026-09-11 20:44:05 +10:00
Durgesh Shekhawat 20ae849a1a fix: use nuqs prevent infinite re-render loop (#2868) 2026-09-11 20:29:24 +10:00
Lucas Smith 6a99b40cba fix: improve action auth flow for passwordless users (#3358)
Selecting password auth failed with a generic "Unauthorized" for users
who signed up via OAuth or passkey, with no way to set one.

Detect the missing password and email the existing reset link from the
signing dialog and security settings. Require a 2FA code and rate limit
update-password.
2026-09-11 14:17:34 +10:00
Catalin Pit e1ad4a2c55 docs: add externalId to send-for-signature workflow example (#3325) 2026-09-10 14:41:05 +03:00
Konrad 82918163c5 fix(i18n): add missing plural in bulk template upload error handling (#3357) 2026-09-10 21:01:17 +10:00
Catalin Pit b97c22a607 fix: pass csp nonce to @hello-pangea/dnd style elements (#3354) 2026-09-10 15:29:41 +10:00
Lucas Smith 389390c884 v2.18.0 2026-09-09 11:34:10 +10:00
Ephraim Duncan c5acba538e chore(auth): remove commented account creation code (#3344) 2026-09-09 11:04:15 +10:00
Lucas Smith f5ab8a8ae3 fix: resolve build errors (#3352) 2026-09-09 11:04:01 +10:00
Ephraim Duncan 3b20c2f960 chore(ui): remove unused text fitting component (#3337) 2026-09-09 10:51:35 +10:00
Lucas Smith 5e8a434141 fix: use react router middleware (#3351) 2026-09-09 10:51:26 +10:00
David Nguyen 6a8bb4be04 fix: improve invalid bulk template upload error handling (#3326) 2026-09-08 21:03:02 +10:00
Catalin Pit 57712ee33a chore: remove extra comments 2026-09-08 13:54:49 +03:00
Catalin Pit 1fb4610a1d feat: improve envelope editor mobile experience 2026-09-07 16:19:05 +03:00
Catalin Pit 0011b949b4 Merge remote-tracking branch 'origin/main' into fix/envelope-editor-mobile-view 2026-09-04 11:08:49 +03:00
Catalin PitandCursor 01b02db201 fix: envelope editor mobile view
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 12:43:58 +03:00
94 changed files with 4747 additions and 5390 deletions
@@ -51,6 +51,7 @@ async function createAndSendDocument(
pdfBuffer: Buffer,
filename: string,
title: string,
externalId: string,
recipients: Recipient[],
): Promise<CreateAndSendResult> {
const recipientPayload = recipients.map((recipient, index) => ({
@@ -89,6 +90,7 @@ async function createAndSendDocument(
JSON.stringify({
type: 'DOCUMENT',
title,
externalId,
recipients: recipientPayload,
meta: {
subject: `Please sign: ${title}`,
@@ -145,6 +147,7 @@ const result = await createAndSendDocument(
pdfBuffer,
'contract.pdf',
'Service Agreement',
'nda-contract-ndac214',
[
{ email: 'client@example.com', name: 'John Smith', role: 'SIGNER' },
{ email: 'manager@company.com', name: 'Jane Doe', role: 'SIGNER' },
@@ -172,6 +175,7 @@ ENVELOPE_RESPONSE=$(curl -s -X POST "${BASE_URL}/envelope/create" \
-F 'payload={
"type": "DOCUMENT",
"title": "Service Agreement",
"externalId": "nda-contract-ndac214",
"recipients": [
{
"email": "client@example.com",
@@ -241,6 +245,8 @@ echo $DISTRIBUTE_RESPONSE | jq '.recipients[] | {email, signingUrl}'
</Tab>
</Tabs>
`externalId` is your application's own reference for this document, such as an invoice number or a database key. Documenso stores it on the envelope and repeats it in every webhook as `payload.externalId`, so your handler can match the event to your record without keeping a lookup table of Documenso IDs. To react when everyone has signed, see [Workflow 4](#workflow-4-wait-for-completion-with-webhooks). To fetch the finished PDF, see [Workflow 5](#workflow-5-download-signed-documents).
---
## Workflow 2: Create Document from Template with Custom Data
+1 -1
View File
@@ -15,7 +15,7 @@
"fumadocs-ui": "16.14.3",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.3.0",
"next": "^16.3.3",
"next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "^19.2.4",
+1 -1
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "16.3.0"
"next": "^16.3.3"
},
"devDependencies": {
"@types/node": "^20",
@@ -1,4 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({
type TBulkSendFormSchema = z.infer<typeof ZBulkSendFormSchema>;
type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string };
export type TemplateBulkSendDialogProps = {
templateId: number;
recipients: Array<{ email: string; name?: string | null }>;
@@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const team = useCurrentTeam();
const [open, setOpen] = useState(false);
const [validationError, setValidationError] = useState<TBulkSendValidationError | null>(null);
const form = useForm<TBulkSendFormSchema>({
resolver: zodResolver(ZBulkSendFormSchema),
defaultValues: {
@@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}
setOpen(value);
if (!value) {
setValidationError(null);
form.reset();
}
};
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
@@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
};
const onSubmit = async (values: TBulkSendFormSchema) => {
setValidationError(null);
try {
const csv = await values.file.text();
await uploadBulkSend({
const result = await uploadBulkSend({
templateId,
teamId: team?.id,
csv: csv,
sendImmediately: values.sendImmediately,
});
if (!result.success) {
setValidationError(result.error);
return;
}
toast({
title: _(msg`Success`),
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
});
setOpen(false);
form.reset();
onSuccess?.();
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`Failed to upload CSV. Please check the file format and try again.`),
variant: 'destructive',
});
const error = AppError.parseError(err);
setValidationError({ type: 'UPLOAD_ERROR', code: error.code });
}
};
return (
<Dialog>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" className="shrink-0" size="sm">
@@ -174,7 +206,10 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setValidationError(null);
onChange(file);
}
}}
@@ -195,7 +230,11 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
type="button"
variant="link"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => onChange(null)}
onClick={() => {
setValidationError(null);
form.resetField('file');
}}
disabled={form.formState.isSubmitting}
>
<X className="h-4 w-4" />
@@ -218,6 +257,72 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
)}
/>
{validationError !== null && (
<Alert variant="destructive">
<AlertDescription className="max-h-32 overflow-y-auto">
{match(validationError)
.with({ type: 'PARSE_ERROR' }, () => (
<Trans>The CSV could not be parsed. Please check the file format and try again.</Trans>
))
.with({ type: 'EMPTY' }, () => (
<Trans>
The CSV does not contain any rows. Please add at least one row of recipient details.
</Trans>
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
<Plural value={rowCount} one="The CSV contains # row." other="The CSV contains # rows." />{' '}
<Plural
value={maxRows}
one="A maximum of # row is allowed per upload."
other="A maximum of # rows is allowed per upload."
/>
</Trans>
))
.with({ type: 'MISSING_COLUMNS' }, ({ missingColumns }) => (
<>
<Trans>
The CSV is missing the following required columns. Please download the template CSV for the
correct format.
</Trans>
<ul className="mt-1 list-inside list-disc">
{missingColumns.map((column) => (
<li key={column} className="font-mono">
{column}
</li>
))}
</ul>
</>
))
.with({ type: 'INVALID_RECIPIENTS' }, ({ rowErrors }) => (
<>
<Trans>The CSV contains invalid recipient emails. Please fix the following rows:</Trans>
<ul className="mt-1 list-inside list-disc">
{rowErrors.map((rowError, index) => (
<li key={index}>
<Trans>
Row {rowError.row}: <span className="font-mono">{rowError.column}</span> must be a valid
email or empty
</Trans>
</li>
))}
</ul>
</>
))
.with({ type: 'UPLOAD_ERROR' }, ({ code }) =>
code === AppErrorCode.LIMIT_EXCEEDED ? (
<Trans>The CSV exceeds the maximum file size.</Trans>
) : (
<Trans>Failed to upload CSV. Please check the file format and try again.</Trans>
),
)
.exhaustive()}
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="sendImmediately"
@@ -240,7 +345,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
/>
<DialogFooter className="mt-4">
<Button variant="secondary" onClick={() => form.reset()} type="button">
<Button
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={form.formState.isSubmitting}
type="button"
>
<Trans>Cancel</Trans>
</Button>
@@ -18,6 +18,8 @@ import { useCallback, useRef } from 'react';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { useCspNonce } from '~/utils/nonce';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -32,6 +34,7 @@ export interface ConfigureDocumentRecipientsProps {
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
const cspNonce = useCspNonce();
const $sensorApi = useRef<SensorAPI | null>(null);
@@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure
/>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -0,0 +1,158 @@
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { Trans } from '@lingui/react/macro';
import type React from 'react';
import { useState } from 'react';
import { type FieldValues, type Path, useFormContext } from 'react-hook-form';
import { z } from 'zod';
/**
* Schema for forms that accept a two factor code. Compose with `.extend()` or `.merge()`.
*/
export const ZTwoFactorCodeFieldSchema = z.object({
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TTwoFactorCodeFieldSchema = z.infer<typeof ZTwoFactorCodeFieldSchema>;
export const hasTwoFactorCode = (data: TTwoFactorCodeFieldSchema) => !!data.totpCode || !!data.backupCode;
type TwoFactorMethod = 'totp' | 'backup';
export type TwoFactorCodeDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
isSubmitting?: boolean;
submitLabel: React.ReactNode;
/**
* Called when the user submits the code. Typically the parent form's submit handler.
*/
onSubmit: () => void;
};
/**
* Collects a TOTP or backup code on top of an existing form, mirroring the
* sign in and disable 2FA dialogs.
*
* Must be rendered inside a `<Form>` whose values include `totpCode` and `backupCode`.
*/
export const TwoFactorCodeDialog = <T extends FieldValues & TTwoFactorCodeFieldSchema>({
open,
onOpenChange,
isSubmitting,
submitLabel,
onSubmit,
}: TwoFactorCodeDialogProps) => {
const form = useFormContext<T>();
const [method, setMethod] = useState<TwoFactorMethod>('totp');
const totpCodeName = 'totpCode' as Path<T>;
const backupCodeName = 'backupCode' as Path<T>;
const onToggleMethod = () => {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod((current) => (current === 'totp' ? 'backup' : 'totp'));
};
const handleOpenChange = (value: boolean) => {
if (isSubmitting) {
return;
}
if (!value) {
form.resetField(totpCodeName);
form.resetField(backupCodeName);
setMethod('totp');
}
onOpenChange(value);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Two-Factor Authentication</Trans>
</DialogTitle>
<DialogDescription>
{method === 'totp' ? (
<Trans>Enter the code from your authenticator app to continue.</Trans>
) : (
<Trans>Enter one of your backup codes to continue.</Trans>
)}
</DialogDescription>
</DialogHeader>
<fieldset disabled={isSubmitting}>
{method === 'totp' && (
<FormField
control={form.control}
name={totpCodeName}
render={({ field }) => (
<FormItem>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6} autoFocus>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{method === 'backup' && (
<FormField
control={form.control}
name={backupCodeName}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" autoComplete="off" autoFocus {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter className="mt-4">
<Button type="button" variant="secondary" onClick={onToggleMethod}>
{method === 'totp' ? <Trans>Use Backup Code</Trans> : <Trans>Use Authenticator</Trans>}
</Button>
<Button type="button" loading={isSubmitting} onClick={onSubmit}>
{submitLabel}
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,53 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { CheckIcon } from 'lucide-react';
import { match } from 'ts-pattern';
/**
* Compact "send me a setup link" button that reports via toast, for settings
* cards where the surrounding layout provides the explanation.
*/
export const PasswordSetupRequestButton = () => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess } = usePasswordSetupRequest({
onSuccess: () => {
toast({
title: _(msg`Check your email`),
description: _(msg`We've sent a link to ${user.email}. Follow it to set your password.`),
duration: 5000,
});
},
onError: (errorCode) => {
toast({
title: _(msg`An error occurred`),
description: match(errorCode)
.with('SIGNIN_DISABLED', () => _(msg`Password sign in is disabled for this instance.`))
.otherwise(() => _(msg`We were unable to send the email. Please try again later.`)),
variant: 'destructive',
});
},
});
if (isSuccess) {
return (
<Button variant="outline" className="flex-shrink-0 bg-background" disabled>
<CheckIcon className="mr-2 h-4 w-4" />
<Trans>Link sent</Trans>
</Button>
);
}
return (
<Button variant="outline" className="flex-shrink-0 bg-background" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
);
};
@@ -0,0 +1,61 @@
import { usePasswordSetupRequest } from '@documenso/lib/client-only/hooks/use-password-setup-request';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
export type PasswordSetupRequestProps = {
className?: string;
};
/**
* Inline "send me a setup link" control with its own sent/error states, for
* contexts like dialogs where a toast would be missed.
*/
export const PasswordSetupRequest = ({ className }: PasswordSetupRequestProps) => {
const { _ } = useLingui();
const { user } = useSession();
const { requestSetupLink, isPending, isSuccess, errorCode } = usePasswordSetupRequest();
if (isSuccess) {
return (
<Alert className={className} variant="neutral">
<AlertTitle>
<Trans>Check your email</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue.
</Trans>
</AlertDescription>
</Alert>
);
}
return (
<div className={className}>
{errorCode && (
<Alert className="mb-4" variant="destructive">
<AlertTitle>
<Trans>An error occurred</Trans>
</AlertTitle>
<AlertDescription>
{match(errorCode)
.with('SIGNIN_DISABLED', () =>
_(msg`Password sign in is disabled for this instance. Please contact support.`),
)
.otherwise(() => _(msg`We were unable to send the email. Please try again or contact support.`))}
</AlertDescription>
</Alert>
)}
<Button type="button" loading={isPending} onClick={requestSetupLink}>
<Trans>Send setup link</Trans>
</Button>
</div>
);
};
+59 -18
View File
@@ -1,6 +1,6 @@
import { authClient } from '@documenso/auth/client';
import type { SessionUser } from '@documenso/auth/server/lib/session/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ZCurrentPasswordSchema, ZPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -11,20 +11,21 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import type { z } from 'zod';
export const ZPasswordFormSchema = z
.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
})
.refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
import { hasTwoFactorCode, TwoFactorCodeDialog, ZTwoFactorCodeFieldSchema } from './2fa/two-factor-code-dialog';
export const ZPasswordFormSchema = ZTwoFactorCodeFieldSchema.extend({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
repeatedPassword: ZPasswordSchema,
}).refine((data) => data.password === data.repeatedPassword, {
message: 'Passwords do not match',
path: ['repeatedPassword'],
});
export type TPasswordFormSchema = z.infer<typeof ZPasswordFormSchema>;
@@ -33,29 +34,51 @@ export type PasswordFormProps = {
user: SessionUser;
};
export const PasswordForm = ({ className }: PasswordFormProps) => {
export const PasswordForm = ({ className, user }: PasswordFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isTwoFactorDialogOpen, setIsTwoFactorDialogOpen] = useState(false);
const form = useForm<TPasswordFormSchema>({
values: {
currentPassword: '',
password: '',
repeatedPassword: '',
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZPasswordFormSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onFormSubmit = async ({ currentPassword, password }: TPasswordFormSchema) => {
const onFormSubmit = async (values: TPasswordFormSchema) => {
const { currentPassword, password, totpCode, backupCode } = values;
// Collect the 2FA code in a dialog once the password fields are valid.
if (user.twoFactorEnabled && !hasTwoFactorCode(values)) {
if (isTwoFactorDialogOpen) {
const message = _(msg`A code is required`);
form.setError('totpCode', { message });
form.setError('backupCode', { message });
}
setIsTwoFactorDialogOpen(true);
return;
}
try {
await authClient.emailPassword.updatePassword({
currentPassword,
password,
totpCode: totpCode || undefined,
backupCode: backupCode || undefined,
});
form.reset();
setIsTwoFactorDialogOpen(false);
toast({
title: _(msg`Password updated`),
@@ -66,9 +89,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('NO_PASSWORD', () => msg`User has no password.`)
.with('INCORRECT_PASSWORD', () => msg`Current password is incorrect.`)
.with('SAME_PASSWORD', () => msg`Your new password cannot be the same as your old password.`)
.with(AppErrorCode.NO_PASSWORD, () => msg`User has no password.`)
.with(AppErrorCode.INCORRECT_PASSWORD, () => msg`Current password is incorrect.`)
.with(AppErrorCode.SAME_PASSWORD, () => msg`Your new password cannot be the same as your old password.`)
.with(
AppErrorCode.INCORRECT_TWO_FACTOR_CODE,
AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS,
() => msg`The two factor code you provided is invalid. Please try again.`,
)
.otherwise(
() => msg`We encountered an unknown error while attempting to update your password. Please try again later.`,
);
@@ -83,7 +111,12 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
return (
<Form {...form}>
<form className={cn('flex w-full flex-col gap-y-4', className)} onSubmit={form.handleSubmit(onFormSubmit)}>
{/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */}
<form
method="post"
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<FormField
control={form.control}
@@ -140,6 +173,14 @@ export const PasswordForm = ({ className }: PasswordFormProps) => {
</Button>
</div>
</form>
<TwoFactorCodeDialog<TPasswordFormSchema>
open={isTwoFactorDialogOpen}
onOpenChange={setIsTwoFactorDialogOpen}
isSubmitting={isSubmitting}
submitLabel={<Trans>Update password</Trans>}
onSubmit={form.handleSubmit(onFormSubmit)}
/>
</Form>
);
};
@@ -1,5 +1,7 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
@@ -7,11 +9,13 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { Loader2Icon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
import { DocumentSigningAuthSetPassword } from './document-signing-auth-set-password';
export type DocumentSigningAuthPasswordProps = {
open: boolean;
@@ -35,8 +39,12 @@ export const DocumentSigningAuthPassword = ({
}: DocumentSigningAuthPasswordProps) => {
const { t } = useLingui();
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const { user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Fetched on demand since this is only needed once the user opts for password auth.
const { data: authMethodsData, isPending: isAuthMethodsPending } = trpc.auth.getAuthMethods.useQuery(undefined, {
enabled: !!user,
});
const form = useForm<TPasswordAuthFormSchema>({
resolver: zodResolver(ZPasswordAuthFormSchema),
@@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
// If the query fails we fall through to the regular password form rather than blocking.
const isPasswordSetupRequired =
!!user && !!authMethodsData && !authMethodsData.authMethods.includes(UserAuthMethod.PASSWORD);
const onFormSubmit = async ({ password }: TPasswordAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
@@ -64,8 +76,6 @@ export const DocumentSigningAuthPassword = ({
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
@@ -79,9 +89,22 @@ export const DocumentSigningAuthPassword = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
if (user && isAuthMethodsPending) {
return (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (isPasswordSetupRequired) {
return <DocumentSigningAuthSetPassword onOpenChange={onOpenChange} />;
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
{/* method="post" so a pre-hydration native submit can't leak the password into the URL. */}
<form method="post" onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
{formErrorCode && (
@@ -0,0 +1,63 @@
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { PasswordSetupRequest } from '~/components/forms/password-setup-request';
export type DocumentSigningAuthSetPasswordProps = {
onOpenChange: (value: boolean) => void;
};
/**
* Shown in place of the password reauth form when the signed in user has no
* password (e.g. they signed up via OAuth or a passkey).
*
* Password based action auth is meant to prove more than possession of a session,
* so rather than letting the session set a password inline we send the user the
* verified reset link and ask them to come back.
*/
export const DocumentSigningAuthSetPassword = ({ onOpenChange }: DocumentSigningAuthSetPasswordProps) => {
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
return (
<div className="space-y-4">
{isEmailPasswordSigninEnabled ? (
<>
<Alert variant="neutral">
<AlertTitle>
<Trans>No password set</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Signing this field requires a password, but your account does not have one. We can email you a link to
set one. Once done, sign in again and return to this document to continue.
</Trans>
</AlertDescription>
</Alert>
<PasswordSetupRequest />
</>
) : (
<Alert variant="warning">
<AlertTitle>
<Trans>Password authentication unavailable</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Your account does not have a password and password sign in is disabled for this instance. Please contact
the document sender to use a different authentication method.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
};
@@ -21,6 +21,7 @@ export type DocumentAttachmentsPopoverProps = {
envelopeId: string;
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
collapseLabelOnMobile?: boolean;
};
const ZAttachmentFormSchema = z.object({
@@ -35,6 +36,7 @@ export const DocumentAttachmentsPopover = ({
envelopeId,
buttonClassName,
buttonSize,
collapseLabelOnMobile = false,
}: DocumentAttachmentsPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -129,7 +131,7 @@ export const DocumentAttachmentsPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<Trans>Attachments</Trans>
{attachments && attachments.data.length > 0 && <span className="ml-1">({attachments.data.length})</span>}
</span>
@@ -28,6 +28,7 @@ import { useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
import { getDistributeErrorMessage } from '~/utils/toast-error-messages';
export type DocumentEditFormProps = {
@@ -42,6 +43,7 @@ const EditDocumentSteps: EditDocumentStep[] = ['settings', 'signers', 'fields',
export const DocumentEditForm = ({ className, initialDocument, documentRootPath }: DocumentEditFormProps) => {
const { toast } = useToast();
const { _ } = useLingui();
const cspNonce = useCspNonce();
const navigate = useNavigate();
@@ -473,6 +475,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddFieldsFormPartial
@@ -19,6 +19,7 @@ import { z } from 'zod';
export type EmbeddedEditorAttachmentPopoverProps = {
buttonClassName?: string;
buttonSize?: 'sm' | 'default';
collapseLabelOnMobile?: boolean;
};
const ZAttachmentFormSchema = z.object({
@@ -32,6 +33,7 @@ type TAttachmentFormSchema = z.infer<typeof ZAttachmentFormSchema>;
export const EmbeddedEditorAttachmentPopover = ({
buttonClassName,
buttonSize,
collapseLabelOnMobile = false,
}: EmbeddedEditorAttachmentPopoverProps) => {
const { toast } = useToast();
const { _ } = useLingui();
@@ -90,7 +92,7 @@ export const EmbeddedEditorAttachmentPopover = ({
<Button variant="outline" className={cn('gap-2', buttonClassName)} size={buttonSize}>
<Paperclip className="h-4 w-4" />
<span>
<span className={cn(collapseLabelOnMobile && 'sr-only sm:not-sr-only')}>
<Trans>Attachments</Trans>
{attachments.length > 0 && <span className="ml-1">({attachments.length})</span>}
</span>
@@ -24,6 +24,7 @@ import {
UserIcon,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -88,11 +89,13 @@ export const fieldButtonList = [
type EnvelopeEditorFieldDragDropProps = {
selectedRecipientId: number | null;
selectedEnvelopeItemId: string | null;
onFieldSelect?: (fieldType: FieldType) => void;
};
export const EnvelopeEditorFieldDragDrop = ({
selectedRecipientId,
selectedEnvelopeItemId,
onFieldSelect,
}: EnvelopeEditorFieldDragDropProps) => {
const { envelope, editorFields, isTemplate, getRecipientColorKey } = useCurrentEnvelopeEditor();
@@ -245,7 +248,10 @@ export const EnvelopeEditorFieldDragDrop = ({
disabled={isFieldsDisabled}
key={field.type}
type="button"
onClick={() => setSelectedField(field.type)}
onClick={() => {
setSelectedField(field.type);
onFieldSelect?.(field.type);
}}
onMouseDown={() => setSelectedField(field.type)}
data-selected={selectedField === field.type ? true : undefined}
className={cn(
@@ -267,27 +273,35 @@ export const EnvelopeEditorFieldDragDrop = ({
))}
</div>
{selectedField && (
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>
)}
{/*
* Portalled to the body because the fields panel ancestor carries a CSS
* transform (the slide-in overlay below `md`), which would otherwise
* become the containing block for this fixed-position preview and pin
* it inside the panel instead of the viewport.
*/}
{selectedField &&
createPortal(
<div
className={cn(
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center rounded-[2px] bg-white font-noto text-muted-foreground ring-2 transition duration-200 [container-type:size] dark:text-muted',
selectedRecipientStyles.base,
selectedField === FieldType.SIGNATURE && 'font-signature',
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
'dark:text-black/60': isFieldWithinBounds,
},
)}
style={{
top: coords.y,
left: coords.x,
height: fieldBounds.current.height,
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{t(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>,
document.body,
)}
</>
);
};
@@ -30,7 +30,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { AlertTriangleIcon, FileTextIcon, MousePointerClickIcon, PencilIcon, SparklesIcon, XIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
@@ -89,6 +89,8 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const [isMobileFieldsPanelOpen, setIsMobileFieldsPanelOpen] = useState(false);
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
[envelope, envelope.recipients],
@@ -187,6 +189,8 @@ export const EnvelopeEditorFieldsPage = () => {
}, []);
const onDetectClick = () => {
setIsMobileFieldsPanelOpen(false);
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
@@ -308,212 +312,259 @@ export const EnvelopeEditorFieldsPage = () => {
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-border border-l bg-background py-4">
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<>
{/* Floating trigger for the fields panel, visible below the `md` breakpoint. Offset to clear the step bar. */}
<Button
type="button"
className="fixed right-4 bottom-[4.5rem] z-30 shadow-lg md:hidden"
onClick={() => setIsMobileFieldsPanelOpen(true)}
>
<MousePointerClickIcon className="mr-2 h-4 w-4" />
<Trans>Fields</Trans>
</Button>
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
{isMobileFieldsPanelOpen && (
<div
role="presentation"
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
onClick={() => setIsMobileFieldsPanelOpen(false)}
/>
)}
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
{/*
* Always mounted and toggled with CSS rather than a Sheet/Drawer: the field
* palette registers window-level mouse listeners that must survive the panel
* closing mid click-to-place, and unmounting would also double-mount the field
* settings forms and AI dialogs. `invisible` keeps the closed overlay out of
* the tab order and accessibility tree.
*/}
<div
className={cn(
'fixed inset-y-0 right-0 z-50 w-80 max-w-[85vw] overflow-y-auto border-border border-l bg-background py-4 transition-[transform,visibility] duration-200',
isMobileFieldsPanelOpen ? 'translate-x-0 shadow-xl' : 'invisible translate-x-full',
'md:visible md:sticky md:top-0 md:z-auto md:h-full md:w-80 md:max-w-none md:flex-shrink-0 md:transform-none md:shadow-none md:transition-none',
)}
</section>
>
<div className="mb-4 flex items-center justify-between px-4 md:hidden">
<h3 className="font-semibold text-foreground text-sm">
<Trans>Fields</Trans>
</h3>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
<Button variant="ghost" size="sm" onClick={() => setIsMobileFieldsPanelOpen(false)}>
<XIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Close</Trans>
</span>
</Button>
</div>
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Selected Recipient</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<EnvelopeRecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) => editorFields.setSelectedRecipient(recipient.id)}
recipients={envelope.recipients}
fields={envelope.fields}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
<Alert className="mt-4" variant="warning">
<AlertDescription>
<Trans>
This recipient can no longer be modified as they have signed a field, or completed the document.
</Trans>
</AlertDescription>
</Alert>
)}
</section>
<Separator className="my-4" />
{/* Add fields section. */}
<section className="px-4">
<h3 className="mb-2 font-semibold text-foreground text-sm">
<Trans>Add Fields</Trans>
</h3>
<EnvelopeEditorFieldDragDrop
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
onFieldSelect={() => setIsMobileFieldsPanelOpen(false)}
/>
{editorConfig.fields?.allowAIDetection && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</>
)}
</section>
{/* Field details section. */}
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
{selectedField && (
<section>
<Separator className="my-4" />
{searchParams.get('devmode') && (
<>
<div className="px-4">
<h3 className="mb-3 font-semibold text-foreground text-sm">
<Trans>Developer Mode</Trans>
</h3>
<div className="space-y-2 rounded-md border border-border bg-muted/50 p-3 text-foreground text-sm">
{selectedField.id && (
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
</span>{' '}
{selectedField.id}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Field ID:</Trans>
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.id}
{selectedField.recipientId}
</p>
)}
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Recipient ID:</Trans>
</span>{' '}
{selectedField.recipientId}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos X:</Trans>
</span>{' '}
{selectedField.positionX.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Pos Y:</Trans>
</span>{' '}
{selectedField.positionY.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Width:</Trans>
</span>{' '}
{selectedField.width.toFixed(2)}
</p>
<p>
<span className="min-w-12 text-muted-foreground">
<Trans>Height:</Trans>
</span>{' '}
{selectedField.height.toFixed(2)}
</p>
</div>
</div>
</div>
<Separator className="my-4" />
</>
)}
<Separator className="my-4" />
</>
)}
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
<div className="px-4 [&_label]:text-foreground/70 [&_label]:text-xs">
<h3 className="font-semibold text-sm">{_(FieldSettingsTypeTranslations[selectedField.type])}</h3>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
{match(selectedField.type)
.with(FieldType.SIGNATURE, () => (
<EditorFieldSignatureForm
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.CHECKBOX, () => (
<EditorFieldCheckboxForm
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DATE, () => (
<EditorFieldDateForm
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.DROPDOWN, () => (
<EditorFieldDropdownForm
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.EMAIL, () => (
<EditorFieldEmailForm
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.INITIALS, () => (
<EditorFieldInitialsForm
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NAME, () => (
<EditorFieldNameForm
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.NUMBER, () => (
<EditorFieldNumberForm
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.RADIO, () => (
<EditorFieldRadioForm
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.with(FieldType.TEXT, () => (
<EditorFieldTextForm
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
onValueChange={(value) => updateSelectedFieldMeta(value)}
/>
))
.otherwise(() => null)}
</div>
</section>
)}
</AnimateGenericFadeInOut>
</div>
</>
)}
</div>
);
@@ -70,17 +70,17 @@ export default function EnvelopeEditorHeader() {
return (
<nav className="w-full border-border border-b bg-background px-4 py-3 md:px-6">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center space-x-4">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-4">
{editorConfig.embedded?.customBrandingLogo ? (
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="h-6 w-auto" />
<img src={`/api/branding/logo/team/${envelope.teamId}`} alt="Logo" className="hidden h-6 w-auto sm:block" />
) : (
<Link to="/">
<Link to="/" className="hidden sm:block">
<BrandingLogo className="h-6 w-auto" />
</Link>
)}
<Separator orientation="vertical" className="h-6 shrink-0" />
<Separator orientation="vertical" className="hidden h-6 shrink-0 sm:block" />
<div className="flex min-w-0 items-center space-x-2">
<div className="flex min-w-0 items-center gap-2">
<EnvelopeItemTitleInput
dataTestId="envelope-title-input"
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
@@ -98,19 +98,19 @@ export default function EnvelopeEditorHeader() {
{envelope.type === EnvelopeType.TEMPLATE && (
<>
{envelope.templateType === TemplateType.PRIVATE && (
<Badge variant="secondary" className="shrink-0">
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
<Trans>Private Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.ORGANISATION && (
<Badge variant="orange" className="shrink-0">
<Badge variant="orange" className="hidden shrink-0 sm:inline-flex">
<Building2Icon className="mr-2 size-4" />
<Trans>Organisation Template</Trans>
</Badge>
)}
{envelope.templateType === TemplateType.PUBLIC && (
<Badge variant="default" className="shrink-0">
<Badge variant="default" className="hidden shrink-0 sm:inline-flex">
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
<Trans>Public Template</Trans>
</Badge>
@@ -118,7 +118,7 @@ export default function EnvelopeEditorHeader() {
{envelope.directLink?.token && (
<TemplateDirectLinkBadge
className="shrink-0 py-1"
className="hidden shrink-0 py-1 sm:flex"
token={envelope.directLink.token}
enabled={envelope.directLink.enabled}
/>
@@ -180,9 +180,9 @@ export default function EnvelopeEditorHeader() {
<div className="flex shrink-0 items-center space-x-2">
{allowAttachments &&
(isEmbedded ? (
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
<EmbeddedEditorAttachmentPopover buttonSize="sm" collapseLabelOnMobile />
) : (
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" />
<DocumentAttachmentsPopover envelopeId={envelope.id} buttonSize="sm" collapseLabelOnMobile />
))}
{editorConfig.settings && (
@@ -202,8 +202,10 @@ export default function EnvelopeEditorHeader() {
documentRootPath={relativePath.documentRootPath}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Send Document</Trans>
<SendIcon className="h-4 w-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
<Trans>Send Document</Trans>
</span>
</Button>
}
/>
@@ -212,8 +214,10 @@ export default function EnvelopeEditorHeader() {
envelope={envelope}
trigger={
<Button size="sm">
<SendIcon className="mr-2 h-4 w-4" />
<Trans>Resend Document</Trans>
<SendIcon className="h-4 w-4 sm:mr-2" />
<span className="sr-only sm:not-sr-only">
<Trans>Resend Document</Trans>
</span>
</Button>
}
/>
@@ -0,0 +1,66 @@
import { Button } from '@documenso/ui/primitives/button';
import { Trans } from '@lingui/react/macro';
import { MonitorIcon, XIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
const DISMISSED_STORAGE_KEY = 'envelopeEditorMobileNoticeDismissed';
// Storage access can throw when the browser blocks it (privacy settings, some
// embedded contexts). A cosmetic notice must never take the editor down, so
// failures are treated as "not dismissed" and the dismissal is not persisted.
const readIsDismissed = () => {
try {
return localStorage.getItem(DISMISSED_STORAGE_KEY) === 'true';
} catch {
return false;
}
};
const persistDismissed = () => {
try {
localStorage.setItem(DISMISSED_STORAGE_KEY, 'true');
} catch {
return;
}
};
/**
* Suggests using a larger screen for the editor. Only shown below the `md`
* breakpoint, and stays hidden once dismissed.
*/
export const EnvelopeEditorMobileNotice = () => {
// Hidden until mounted so the server and first client render agree, and so a
// previously dismissed notice never flashes.
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(!readIsDismissed());
}, []);
const onDismiss = () => {
setIsVisible(false);
persistDismissed();
};
if (!isVisible) {
return null;
}
return (
<div className="flex shrink-0 items-center gap-3 border-border border-b bg-muted/50 px-4 py-2 text-muted-foreground text-sm md:hidden">
<MonitorIcon className="h-4 w-4 shrink-0" aria-hidden />
<p className="flex-1">
<Trans>For the best experience, use the editor on a desktop.</Trans>
</p>
<Button variant="ghost" size="sm" className="-mr-2 h-8 w-8 shrink-0 p-0" onClick={onDismiss}>
<XIcon className="h-4 w-4" />
<span className="sr-only">
<Trans>Close</Trans>
</span>
</Button>
</div>
);
};
@@ -0,0 +1,208 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import {
CopyPlusIcon,
DownloadCloudIcon,
FileOutputIcon,
LinkIcon,
type LucideIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
} from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from './envelope-editor-settings-dialog';
const useEnvelopeEditorQuickActionVisibility = () => {
const { envelope, editorConfig, isDocument, isTemplate } = useCurrentEnvelopeEditor();
const { actions } = editorConfig;
return {
settings: Boolean(editorConfig.settings),
distribute: isDocument && actions.allowDistributing,
directLink: isTemplate && actions.allowDirectLink,
duplicate: actions.allowDuplication,
saveAsTemplate: isDocument && actions.allowSaveAsTemplate,
download: actions.allowDownloadPDF,
// Check envelope ID since it can be in embedded create mode.
delete: actions.allowDeletion && Boolean(envelope.id),
};
};
export const useHasEnvelopeEditorQuickActions = () =>
Object.values(useEnvelopeEditorQuickActionVisibility()).some(Boolean);
type QuickActionButtonProps = ComponentProps<typeof Button> & {
icon: LucideIcon;
label: ReactNode;
showLabel: boolean;
};
// Used as a Radix `asChild` trigger, so the injected props must reach the button.
const QuickActionButton = ({ icon: Icon, label, showLabel, ...props }: QuickActionButtonProps) => (
<Button variant="ghost" size="sm" className="w-full justify-start" {...props}>
<Icon className="h-4 w-4" />
{showLabel && <span className="ml-2">{label}</span>}
</Button>
);
export type EnvelopeEditorQuickActionsProps = {
showLabels: boolean;
};
export const EnvelopeEditorQuickActions = ({ showLabels }: EnvelopeEditorQuickActionsProps) => {
const { t } = useLingui();
const navigate = useNavigate();
const { envelope, isDocument, relativePath, syncEnvelope } = useCurrentEnvelopeEditor();
const visibility = useEnvelopeEditorQuickActionVisibility();
return (
<div className="space-y-3 [&_.lucide]:text-muted-foreground">
{visibility.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<QuickActionButton
icon={SettingsIcon}
title={t`Settings`}
showLabel={showLabels}
label={isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
/>
}
/>
)}
{visibility.distribute && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<QuickActionButton
icon={SendIcon}
title={t`Send Envelope`}
showLabel={showLabels}
label={<Trans>Send Document</Trans>}
/>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<QuickActionButton
icon={SendIcon}
title={t`Resend Envelope`}
showLabel={showLabels}
label={<Trans>Resend Document</Trans>}
/>
}
/>
</>
)}
{visibility.directLink && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<QuickActionButton
icon={LinkIcon}
title={t`Direct Link`}
showLabel={showLabels}
label={<Trans>Direct Link</Trans>}
/>
}
/>
)}
{visibility.duplicate && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<QuickActionButton
icon={CopyPlusIcon}
title={t`Duplicate Envelope`}
showLabel={showLabels}
label={isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
/>
}
/>
)}
{visibility.saveAsTemplate && (
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
trigger={
<QuickActionButton
icon={FileOutputIcon}
title={t`Save as Template`}
showLabel={showLabels}
label={<Trans>Save as Template</Trans>}
/>
}
/>
)}
{visibility.download && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
isLegacy={envelope.internalVersion === 1}
envelopeItems={envelope.envelopeItems}
trigger={
<QuickActionButton
icon={DownloadCloudIcon}
title={t`Download PDF`}
showLabel={showLabels}
label={<Trans>Download PDF</Trans>}
/>
}
/>
)}
{visibility.delete && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<QuickActionButton
icon={Trash2Icon}
title={t`Delete Envelope`}
showLabel={showLabels}
label={isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
/>
}
onDelete={async () => {
await navigate(
envelope.type === EnvelopeType.DOCUMENT ? relativePath.documentRootPath : relativePath.templateRootPath,
);
}}
/>
)}
</div>
);
};
@@ -45,6 +45,7 @@ import { isDeepEqual } from 'remeda';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
export const EnvelopeEditorRecipientForm = () => {
const { envelope, setRecipientsDebounced, updateEnvelope, editorRecipients, isEmbedded, editorConfig } =
@@ -52,6 +53,7 @@ export const EnvelopeEditorRecipientForm = () => {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const cspNonce = useCspNonce();
const { t } = useLingui();
const { toast } = useToast();
@@ -590,9 +592,15 @@ export const EnvelopeEditorRecipientForm = () => {
const recipientCountLimit = organisation.organisationClaim.recipientCount;
const isOverRecipientLimit = recipientCountLimit > 0 && signers.length > recipientCountLimit;
const aiDetectionLabel = team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
);
return (
<Card backdropBlur={false} className="border">
<CardHeader className="flex flex-row justify-between">
<CardHeader className="flex flex-col justify-between gap-4 space-y-0 p-4 sm:flex-row sm:p-6">
<div>
<CardTitle>
<Trans>Recipients</Trans>
@@ -602,7 +610,7 @@ export const EnvelopeEditorRecipientForm = () => {
</CardDescription>
</div>
<div className="flex flex-row items-center space-x-2">
<div className="flex flex-row flex-wrap items-center gap-2">
{editorConfig.recipients?.allowAIDetection && (
<Tooltip>
<TooltipTrigger asChild>
@@ -610,27 +618,23 @@ export const EnvelopeEditorRecipientForm = () => {
variant="outline"
type="button"
size="sm"
className="order-last w-full sm:order-none sm:w-auto"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
<span className="ml-2 sm:hidden">{aiDetectionLabel}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
<TooltipContent>{aiDetectionLabel}</TooltipContent>
</Tooltip>
)}
{(!isEmbedded || hasCurrentEditorInfo) && (
<Button
variant="outline"
className="flex flex-row items-center"
className="flex flex-1 flex-row items-center sm:flex-none"
size="sm"
disabled={isSubmitting || isUserAlreadyARecipient}
onClick={() => onAddSelfSigner()}
@@ -653,7 +657,7 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{isOverRecipientLimit && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>
@@ -795,6 +799,7 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -837,14 +842,17 @@ export const EnvelopeEditorRecipientForm = () => {
data-native-id={signer.id}
disabled={isSubmitting || !canRecipientBeModified(signer.id)}
className={cn('pb-2', {
'border-b pb-4': showAdvancedSettings && index !== signers.length - 1,
// Recipients that span several lines (stacked below `sm`, or with advanced
// settings shown) are separated by a divider.
'border-b pb-4': index !== signers.length - 1,
'sm:border-b-0 sm:pb-2': !showAdvancedSettings && index !== signers.length - 1,
'pt-2': showAdvancedSettings && index === 0,
'pr-3': isSigningOrderSequential,
'sm:pr-3': isSigningOrderSequential,
})}
>
<div className="flex flex-row items-center gap-x-2">
<div className="flex flex-row flex-wrap items-center gap-2 sm:flex-nowrap">
{isSigningOrderSequential && isCcRecipient(signer) && (
<div className="mt-auto h-10 w-[4.25rem] flex-shrink-0" />
<div className="mt-auto hidden h-10 w-[4.25rem] flex-shrink-0 sm:block" />
)}
{isSigningOrderSequential && !isCcRecipient(signer) && (
@@ -853,11 +861,14 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.signingOrder`}
render={({ field }) => (
<FormItem
className={cn('mt-auto flex items-center gap-x-1 space-y-0', {
'mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
})}
className={cn(
'order-1 mt-auto flex items-center gap-x-1 space-y-0 sm:order-none',
{
'sm:mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.signingOrder,
},
)}
>
<GripVerticalIcon className="h-5 w-5 flex-shrink-0 opacity-40" />
<FormControl>
@@ -889,19 +900,23 @@ export const EnvelopeEditorRecipientForm = () => {
/>
)}
<span className="order-1 min-w-0 flex-1 truncate font-medium text-sm sm:hidden">
<Trans>Recipient {index + 1}</Trans>
</span>
<FormField
control={form.control}
name={`signers.${index}.email`}
render={({ field }) => (
<FormItem
className={cn('relative w-full', {
'mb-6':
className={cn('relative order-2 w-full sm:order-none', {
'sm:mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.email,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
{!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:sr-only')}>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -941,14 +956,14 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.name`}
render={({ field }) => (
<FormItem
className={cn('w-full', {
'mb-6':
className={cn('order-3 w-full sm:order-none', {
'sm:mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.name,
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel>
{!showAdvancedSettings && (
<FormLabel className={cn(index !== 0 && 'sm:sr-only')}>
<Trans>Name</Trans>
</FormLabel>
)}
@@ -987,8 +1002,8 @@ export const EnvelopeEditorRecipientForm = () => {
name={`signers.${index}.role`}
render={({ field }) => (
<FormItem
className={cn('mt-auto w-fit', {
'mb-6':
className={cn('order-1 mt-auto w-fit sm:order-none', {
'sm:mb-6':
form.formState.errors.signers?.[index] &&
!form.formState.errors.signers[index]?.role,
})}
@@ -1018,8 +1033,8 @@ export const EnvelopeEditorRecipientForm = () => {
<Button
variant="ghost"
className={cn('mt-auto px-2', {
'mb-6': form.formState.errors.signers?.[index],
className={cn('order-1 mt-auto px-2 sm:order-none', {
'sm:mb-6': form.formState.errors.signers?.[index],
})}
data-testid="remove-signer-button"
disabled={
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
import type { TEnvelopeEditorSettings } from '@documenso/lib/types/envelope-editor';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { BellRingIcon, type LucideIcon, MailIcon, SettingsIcon, ShieldIcon } from 'lucide-react';
type EnvelopeEditorSettingsConfig = NonNullable<TEnvelopeEditorSettings['settings']>;
export type EnvelopeEditorSettingsTabId = 'general' | 'reminders' | 'notifications' | 'security';
export type EnvelopeEditorSettingsTab = {
id: EnvelopeEditorSettingsTabId;
title: MessageDescriptor;
description: MessageDescriptor;
icon: LucideIcon;
isEnabled: (settings: EnvelopeEditorSettingsConfig) => boolean;
};
export const ENVELOPE_EDITOR_SETTINGS_TABS: EnvelopeEditorSettingsTab[] = [
{
id: 'general',
title: msg`General`,
description: msg`Configure document settings and options before sending.`,
icon: SettingsIcon,
isEnabled: () => true,
},
{
id: 'reminders',
title: msg`Reminders`,
description: msg`Configure signing reminder settings for the document.`,
icon: BellRingIcon,
isEnabled: (settings) => settings.allowConfigureReminders,
},
{
id: 'notifications',
title: msg`Notifications`,
description: msg`Configure notification settings for the document.`,
icon: MailIcon,
isEnabled: (settings) => settings.allowConfigureDistribution,
},
{
id: 'security',
title: msg`Security`,
description: msg`Configure security settings for the document.`,
icon: ShieldIcon,
isEnabled: () => true,
},
];
export type EnvelopeEditorSettingsTabsProps = {
settings: EnvelopeEditorSettingsConfig;
activeTab: EnvelopeEditorSettingsTabId;
onActiveTabChange: (tab: EnvelopeEditorSettingsTabId) => void;
};
export const EnvelopeEditorSettingsTabs = ({
settings,
activeTab,
onActiveTabChange,
}: EnvelopeEditorSettingsTabsProps) => {
const { t } = useLingui();
const visibleTabs = ENVELOPE_EDITOR_SETTINGS_TABS.filter((tab) => tab.isEnabled(settings));
return (
<>
<nav className="scrollbar-hidden flex overflow-x-auto px-4 md:hidden">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
aria-current={activeTab === tab.id ? 'true' : undefined}
onClick={(event) => {
onActiveTabChange(tab.id);
event.currentTarget.scrollIntoView({ inline: 'nearest', block: 'nearest' });
}}
className={cn(
'shrink-0 whitespace-nowrap border-b-2 px-3 py-2.5 font-medium text-sm transition-colors',
activeTab === tab.id
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{t(tab.title)}
</button>
))}
</nav>
<nav className="mb-8 hidden w-full flex-col items-start gap-y-2 px-4 md:flex">
{visibleTabs.map((tab) => (
<Button
key={tab.id}
variant="ghost"
aria-current={activeTab === tab.id ? 'true' : undefined}
className={cn('w-full justify-start', {
'bg-secondary': activeTab === tab.id,
})}
onClick={() => onActiveTabChange(tab.id)}
>
<tab.icon className="mr-2 h-5 w-5" />
{t(tab.title)}
</Button>
))}
</nav>
</>
);
};
@@ -26,6 +26,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { useCspNonce } from '~/utils/nonce';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
@@ -42,6 +43,7 @@ type LocalFile = {
export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const cspNonce = useCspNonce();
const { t, i18n } = useLingui();
const { maximumEnvelopeItemCount, remaining } = useLimits();
@@ -462,13 +464,13 @@ export const EnvelopeEditorUploadPage = () => {
};
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<div className="mx-auto max-w-4xl space-y-6 p-4 md:p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardHeader className="p-4 pb-3 sm:p-6 sm:pb-3">
<CardTitle>
<Trans>Documents</Trans>
</CardTitle>
@@ -477,13 +479,13 @@ export const EnvelopeEditorUploadPage = () => {
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="p-4 pt-0 sm:p-6 sm:pt-0">
{uploadConfig?.allowUpload && (
<DocumentDropzone
data-testid="envelope-item-dropzone"
onDrop={onFileDrop}
allowMultiple
className="pt-6 pb-4"
className="sm:pt-6 sm:pb-4"
disabled={dropzoneDisabledMessage !== null}
disabledMessage={dropzoneDisabledMessage || undefined}
disabledHeading={msg`Upload disabled`}
@@ -494,7 +496,7 @@ export const EnvelopeEditorUploadPage = () => {
{/* Uploaded Files List */}
<div className="mt-4">
<DragDropContext onDragEnd={onDragEnd}>
<DragDropContext nonce={cspNonce} onDragEnd={onDragEnd}>
<Droppable droppableId="files">
{(provided) => (
<div
@@ -647,7 +649,7 @@ export const EnvelopeEditorUploadPage = () => {
<EnvelopeEditorRecipientForm />
{editorConfig.general.allowAddFieldsStep && (
<div className="flex justify-end">
<div className="hidden justify-end md:flex">
<Button type="button" onClick={() => void navigateToStep('addFields')}>
<Trans>Add Fields</Trans>
</Button>
@@ -1,50 +1,38 @@
import type { EnvelopeEditorStep } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@documenso/ui/primitives/sheet';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { motion } from 'framer-motion';
import {
ArrowLeftIcon,
CopyPlusIcon,
DownloadCloudIcon,
EyeIcon,
FileOutputIcon,
LinkIcon,
type LucideIcon,
MoreHorizontalIcon,
MousePointerIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
UploadIcon,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
import { EnvelopeDistributeDialog } from '~/components/dialogs/envelope-distribute-dialog';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '~/components/dialogs/envelope-duplicate-dialog';
import { EnvelopeRedistributeDialog } from '~/components/dialogs/envelope-redistribute-dialog';
import { EnvelopeSaveAsTemplateDialog } from '~/components/dialogs/envelope-save-as-template-dialog';
import { TemplateDirectLinkDialog } from '~/components/dialogs/template-direct-link-dialog';
import { EnvelopeEditorSettingsDialog } from '~/components/general/envelope-editor/envelope-editor-settings-dialog';
import { EnvelopeEditorFieldsPage } from './envelope-editor-fields-page';
import EnvelopeEditorHeader from './envelope-editor-header';
import { EnvelopeEditorMobileNotice } from './envelope-editor-mobile-notice';
import { EnvelopeEditorPreviewPage } from './envelope-editor-preview-page';
import { EnvelopeEditorQuickActions, useHasEnvelopeEditorQuickActions } from './envelope-editor-quick-actions';
import { EnvelopeEditorUploadPage } from './envelope-editor-upload-page';
type EnvelopeEditorStepData = {
id: string;
title: MessageDescriptor;
/** Short label used where space is limited, such as the mobile step bar. */
shortTitle: MessageDescriptor;
icon: LucideIcon;
description: MessageDescriptor;
};
@@ -52,6 +40,7 @@ type EnvelopeEditorStepData = {
const UPLOAD_STEP = {
id: 'upload',
title: msg`Document & Recipients`,
shortTitle: msg`Documents`,
icon: UploadIcon,
description: msg`Upload documents and add recipients`,
};
@@ -59,6 +48,7 @@ const UPLOAD_STEP = {
const ADD_FIELDS_STEP = {
id: 'addFields',
title: msg`Add Fields`,
shortTitle: msg`Fields`,
icon: MousePointerIcon,
description: msg`Place and configure form fields in the document`,
};
@@ -66,6 +56,7 @@ const ADD_FIELDS_STEP = {
const PREVIEW_STEP = {
id: 'preview',
title: msg`Preview`,
shortTitle: msg`Preview`,
icon: EyeIcon,
description: msg`Preview the document before sending`,
};
@@ -73,32 +64,20 @@ const PREVIEW_STEP = {
export const EnvelopeEditor = () => {
const { t } = useLingui();
const navigate = useNavigate();
const { editorConfig, isDocument, relativePath, navigateToStep, flushAutosave, resetForms } =
useCurrentEnvelopeEditor();
const {
envelope,
editorConfig,
isDocument,
isTemplate,
relativePath,
navigateToStep,
syncEnvelope,
flushAutosave,
resetForms,
} = useCurrentEnvelopeEditor();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// The mobile "More" sheet holds the quick actions and the return link, so it
// is only offered when at least one of them applies.
const hasQuickActions = useHasEnvelopeEditorQuickActions();
const hasMobileMenu = hasQuickActions || !editorConfig.embedded;
const {
general: { minimizeLeftSidebar, allowUploadAndRecipientStep, allowAddFieldsStep, allowPreviewStep },
actions: {
allowDistributing,
allowDirectLink,
allowDuplication,
allowSaveAsTemplate,
allowDownloadPDF,
allowDeletion,
},
} = editorConfig;
const envelopeEditorSteps = useMemo(() => {
@@ -175,16 +154,21 @@ export const EnvelopeEditor = () => {
const currentStepData = envelopeEditorSteps.find((step) => step.id === searchParamsStep) || envelopeEditorSteps[0];
return (
<div className="h-screen w-screen bg-envelope-editor-background">
<div className="flex h-[100dvh] w-full flex-col bg-envelope-editor-background">
<EnvelopeEditorHeader />
{!editorConfig.embedded && <EnvelopeEditorMobileNotice />}
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<div className="flex min-h-0 w-full flex-1">
{/* Left Section - Step Navigation. Hidden below `md`, where the bottom bar takes over. */}
<div
className={cn('flex w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4', {
'w-14': minimizeLeftSidebar,
})}
className={cn(
'hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-border border-r bg-background py-4 md:flex',
{
'w-14': minimizeLeftSidebar,
},
)}
>
{/* Left section step selector. */}
{minimizeLeftSidebar ? (
@@ -318,7 +302,7 @@ export const EnvelopeEditor = () => {
{/* Quick Actions. */}
<div
className={cn('space-y-3 px-4 [&_.lucide]:text-muted-foreground', {
className={cn('space-y-3 px-4', {
'px-2': minimizeLeftSidebar,
})}
>
@@ -328,166 +312,7 @@ export const EnvelopeEditor = () => {
</h4>
)}
{editorConfig.settings && (
<EnvelopeEditorSettingsDialog
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Settings`)}>
<SettingsIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Document Settings</Trans> : <Trans>Template Settings</Trans>}
</span>
)}
</Button>
}
/>
)}
{isDocument && allowDistributing && (
<>
<EnvelopeDistributeDialog
documentRootPath={relativePath.documentRootPath}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Send Envelope`)}>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Send Document</Trans>
</span>
)}
</Button>
}
/>
<EnvelopeRedistributeDialog
envelope={envelope}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Resend Envelope`)}>
<SendIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Resend Document</Trans>
</span>
)}
</Button>
}
/>
</>
)}
{isTemplate && allowDirectLink && (
<TemplateDirectLinkDialog
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
directLink={envelope.directLink}
recipients={envelope.recipients}
onCreateSuccess={async () => await syncEnvelope()}
onDeleteSuccess={async () => await syncEnvelope()}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Direct Link`)}>
<LinkIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Direct Link</Trans>
</span>
)}
</Button>
}
/>
)}
{allowDuplication && (
<EnvelopeDuplicateDialog
envelopeId={envelope.id}
envelopeType={envelope.type}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Duplicate Envelope`)}>
<CopyPlusIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</span>
)}
</Button>
}
/>
)}
{allowSaveAsTemplate && isDocument && (
<EnvelopeSaveAsTemplateDialog
envelopeId={envelope.id}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Save as Template`)}>
<FileOutputIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Save as Template</Trans>
</span>
)}
</Button>
}
/>
)}
{allowDownloadPDF && (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
isLegacy={envelope.internalVersion === 1}
envelopeItems={envelope.envelopeItems}
trigger={
<Button variant="ghost" size="sm" className="w-full justify-start" title={t(msg`Download PDF`)}>
<DownloadCloudIcon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
<Trans>Download PDF</Trans>
</span>
)}
</Button>
}
/>
)}
{/* Check envelope ID since it can be in embedded create mode. */}
{allowDeletion && envelope.id && (
<EnvelopeDeleteDialog
id={envelope.id}
type={envelope.type}
status={envelope.status}
title={envelope.title}
canManageDocument={true}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start"
title={t(msg`Delete Envelope`)}
>
<Trash2Icon className="h-4 w-4" />
{!minimizeLeftSidebar && (
<span className="ml-2">
{isDocument ? <Trans>Delete Document</Trans> : <Trans>Delete Template</Trans>}
</span>
)}
</Button>
}
onDelete={async () => {
await navigate(
envelope.type === EnvelopeType.DOCUMENT
? relativePath.documentRootPath
: relativePath.templateRootPath,
);
}}
/>
)}
<EnvelopeEditorQuickActions showLabels={!minimizeLeftSidebar} />
</div>
{/* Footer of left sidebar. */}
@@ -518,21 +343,89 @@ export const EnvelopeEditor = () => {
)}
</div>
{/* Main Content - Changes based on current step */}
<div className="flex-1 overflow-y-auto">
{match({
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
.otherwise(() => null)}
<div className="flex min-w-0 flex-1 flex-col">
{/* Main Content - Changes based on current step */}
<div className="min-h-0 flex-1 overflow-y-auto">
{match({
pageToRender,
allowUploadAndRecipientStep,
allowAddFieldsStep,
allowPreviewStep,
})
.with({ pageToRender: 'loading' }, () => <SpinnerBox className="py-32" />)
.with({ pageToRender: 'upload', allowUploadAndRecipientStep: true }, () => <EnvelopeEditorUploadPage />)
.with({ pageToRender: 'addFields', allowAddFieldsStep: true }, () => <EnvelopeEditorFieldsPage />)
.with({ pageToRender: 'preview', allowPreviewStep: true }, () => <EnvelopeEditorPreviewPage />)
.otherwise(() => null)}
</div>
{/* Mobile step bar. Replaces the sidebar below `md`. */}
<nav
className="flex h-14 shrink-0 items-stretch border-border border-t bg-background md:hidden"
aria-label={t`Editor steps`}
>
{envelopeEditorSteps.map((step) => {
const Icon = step.icon;
const isActive = searchParamsStep === step.id;
return (
<button
key={step.id}
data-testid={`envelope-editor-mobile-step-${step.id}`}
type="button"
aria-current={isActive ? 'step' : undefined}
className={cn(
'flex flex-1 flex-col items-center justify-center gap-1 font-medium text-[11px] transition-colors',
isActive ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground hover:text-foreground',
)}
onClick={() => void navigateToStep(step.id as EnvelopeEditorStep)}
>
<Icon className="h-5 w-5" />
{t(step.shortTitle)}
</button>
);
})}
{hasMobileMenu && (
<button
type="button"
className="flex flex-1 flex-col items-center justify-center gap-1 font-medium text-[11px] text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setIsMobileMenuOpen(true)}
>
<MoreHorizontalIcon className="h-5 w-5" />
<Trans>More</Trans>
</button>
)}
</nav>
</div>
</div>
{/* Mobile quick actions, opened from the "More" item in the step bar. */}
{hasMobileMenu && (
<Sheet open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
<SheetContent className="flex w-full max-w-[350px] flex-col overflow-y-auto">
<SheetHeader className="text-left">
<SheetTitle>
<Trans>Quick Actions</Trans>
</SheetTitle>
</SheetHeader>
<EnvelopeEditorQuickActions showLabels />
{!editorConfig.embedded && (
<Button variant="ghost" className="mt-auto w-full justify-start" asChild>
<Link to={relativePath.basePath}>
<ArrowLeftIcon className="h-4 w-4 flex-shrink-0" />
<span className="ml-2">
{isDocument ? <Trans>Return to documents</Trans> : <Trans>Return to templates</Trans>}
</span>
</Link>
</Button>
)}
</SheetContent>
</Sheet>
)}
</div>
);
};
@@ -25,6 +25,7 @@ import { z } from 'zod';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { useCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
export type TemplateEditFormProps = {
className?: string;
@@ -38,6 +39,7 @@ const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const TemplateEditForm = ({ initialTemplate, className, templateRootPath }: TemplateEditFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const cspNonce = useCspNonce();
const navigate = useNavigate();
const team = useCurrentTeam();
@@ -339,6 +341,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
onSubmit={onAddTemplatePlaceholderFormSubmit}
onAutoSave={onAddTemplatePlaceholderFormAutoSave}
isDocumentPdfLoaded={isDocumentPdfLoaded}
nonce={cspNonce}
/>
<AddTemplateFieldsFormPartial
+4 -3
View File
@@ -7,10 +7,11 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import type { EntryContext, RouterContextProvider } from 'react-router';
import { ServerRouter } from 'react-router';
import { langCookie } from './storage/lang-cookie.server';
import { nonceContext } from './utils/nonce';
export const streamTimeout = 5_000;
@@ -19,7 +20,7 @@ export default async function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
loadContext: RouterContextProvider,
) {
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -33,7 +34,7 @@ export default async function handleRequest(
// scripts it injects (route manifest, hydration data, module preloads).
// The same nonce is also exposed to the React tree via the root loader so
// our own inline scripts/styles can carry it.
const nonce = loadContext.nonce || undefined;
const nonce = loadContext.get(nonceContext) || undefined;
return new Promise((resolve, reject) => {
let shellRendered = false;
+13
View File
@@ -0,0 +1,13 @@
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { type MiddlewareFunction, redirect } from 'react-router';
export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => {
const { user } = await getOptionalSession(request);
if (!user || !isAdmin(user)) {
throw redirect('/');
}
return next();
};
+8
View File
@@ -0,0 +1,8 @@
import type { MiddlewareFunction } from 'react-router';
import { getRequestNonce } from '../../server/load-context';
import { nonceContext } from '../utils/nonce';
export const nonceMiddleware: MiddlewareFunction = ({ context }) => {
context.set(nonceContext, getRequestNonce());
};
+5 -2
View File
@@ -23,13 +23,16 @@ import {
useMatches,
} from 'react-router';
import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes';
import { nonceMiddleware } from '~/middleware/nonce';
import type { Route } from './+types/root';
import stylesheet from './app.css?url';
import { GenericErrorLayout } from './components/general/generic-error-layout';
import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta';
import { nonce } from './utils/nonce';
import { nonce, nonceContext } from './utils/nonce';
export const middleware = [nonceMiddleware];
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
@@ -74,7 +77,7 @@ export async function loader({ context, request }: Route.LoaderArgs) {
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
nonce: context.nonce,
nonce: context.get(nonceContext),
session: session.isAuthenticated
? {
user: session.user,
@@ -1,4 +1,4 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { isAdmin } from '@documenso/lib/utils/is-admin';
import { cn } from '@documenso/ui/lib/utils';
@@ -20,16 +20,18 @@ import {
import { Link, Outlet, redirect, useLocation } from 'react-router';
import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner';
import { adminMiddleware } from '~/middleware/admin';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/_layout';
export function meta() {
return appMetaTags(msg`Admin`);
}
export const middleware = [adminMiddleware];
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
const { user } = await getOptionalSession(request);
const license = await LicenseClient.getInstance()?.getCachedLicense();
@@ -1,9 +1,7 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -24,32 +22,10 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -59,8 +35,8 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by claim ID or name`}
className="mb-4"
/>
@@ -1,8 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,32 +9,10 @@ import { AdminEmailTransportsTable } from '~/components/tables/admin-email-trans
export default function AdminEmailTransportsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -46,8 +22,8 @@ export default function AdminEmailTransportsPage() {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by name or from address`}
className="mb-4"
/>
@@ -1,4 +1,3 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -6,8 +5,9 @@ import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import {
@@ -52,14 +52,17 @@ export default function OrganisationStats() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const [{ query: searchQuery }, setSearchFilters] = useQueryStates(
{
query: parseAsString.withDefault(''),
page: parseAsInteger,
},
{ shallow: false, limitUrlUpdates: debounce(500) },
);
const [displayMode, setDisplayMode] = useState<OrganisationStatsDisplayMode>('usage');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
@@ -71,29 +74,12 @@ export default function OrganisationStats() {
const claimOptions = claimsData?.data ?? [];
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
params.delete('page');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onSearchQueryChange = (value: string) => {
void setSearchFilters({
query: value || null,
page: null,
});
};
const onPeriodChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
@@ -128,8 +114,8 @@ export default function OrganisationStats() {
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
placeholder={t`Search by organisation name, URL or ID`}
className="flex-1"
/>
@@ -1,8 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -10,32 +8,10 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
export default function Organisations() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -43,8 +19,8 @@ export default function Organisations() {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
className="mb-4"
/>
@@ -1,10 +1,9 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { Link, useLocation, useSearchParams } from 'react-router';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
@@ -15,35 +14,16 @@ import { OrganisationMembersDataTable } from '~/components/tables/organisation-m
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
@@ -57,8 +37,8 @@ export default function TeamsSettingsMembersPage() {
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={_(msg`Search`)}
/>
@@ -1,8 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,27 +9,10 @@ import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-t
export default function OrganisationSettingsTeamsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -40,8 +21,8 @@ export default function OrganisationSettingsTeamsPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,6 +1,8 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { prisma } from '@documenso/prisma';
import { isSigninEnabledForProvider } from '@documenso/lib/constants/auth';
import { getUserAuthMethods } from '@documenso/lib/server-only/user/get-user-auth-methods';
import { UserAuthMethod } from '@documenso/lib/types/user-auth-method';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -12,6 +14,7 @@ import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-au
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog';
import { PasswordForm } from '~/components/forms/password';
import { PasswordSetupRequestButton } from '~/components/forms/password-setup-request-button';
import { SettingsHeader } from '~/components/general/settings-header';
import { appMetaTags } from '~/utils/meta';
@@ -24,33 +27,10 @@ export function meta() {
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getSession(request);
// Todo: Use providers instead after RR7 migration.
// const accounts = await prisma.account.findMany({
// where: {
// userId: user.id,
// },
// select: {
// provider: true,
// },
// });
// const providers = accounts.map((account) => account.provider);
// let hasEmailPasswordAccount = providers.includes('DOCUMENSO');
const hasEmailPasswordAccount: boolean = await prisma.user
.count({
where: {
id: user.id,
password: {
not: null,
},
},
})
.then((value) => value > 0);
const authMethods = await getUserAuthMethods({ userId: user.id });
return {
// providers,
hasEmailPasswordAccount,
hasEmailPasswordAccount: authMethods.includes(UserAuthMethod.PASSWORD),
};
}
@@ -60,14 +40,36 @@ export default function SettingsSecurity({ loaderData }: Route.ComponentProps) {
const { _ } = useLingui();
const { user } = useSession();
const isEmailPasswordSigninEnabled = isSigninEnabledForProvider('email');
return (
<div>
<SettingsHeader
title={_(msg`Security`)}
subtitle={_(msg`Here you can manage your password and security settings.`)}
/>
{hasEmailPasswordAccount && <PasswordForm user={user} />}
{!hasEmailPasswordAccount && isEmailPasswordSigninEnabled && (
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
<Trans>Set a password</Trans>
</AlertTitle>
<AlertDescription className="mr-4">
<Trans>
Your account has no password. Add one to sign in with your email and to sign documents that require it.
We'll email you a link.
</Trans>
</AlertDescription>
</div>
<PasswordSetupRequestButton />
</Alert>
)}
<Alert className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<div className="mb-4 sm:mb-0">
<AlertTitle>
@@ -1,11 +1,9 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -16,34 +14,12 @@ import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsGroupsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const everyoneGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
@@ -61,8 +37,8 @@ export default function TeamsSettingsGroupsPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,8 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,32 +9,10 @@ import { TeamMembersTable } from '~/components/tables/team-members-table';
export default function TeamsSettingsMembersPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -45,8 +21,8 @@ export default function TeamsSettingsMembersPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,4 +1,3 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
@@ -20,7 +19,8 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { CheckCircle2Icon, ChevronRightIcon, PencilIcon, TerminalIcon, XCircleIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useMemo } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
@@ -51,13 +51,14 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
const { toast } = useToast();
const { pathname } = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const parsedSearchParams = WebhookSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
@@ -81,26 +82,6 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
query: parsedSearchParams.query,
});
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
@@ -252,8 +233,8 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
<div className="mt-4">
<div className="mb-4 flex flex-row items-center justify-between gap-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by ID`}
/>
+7 -1
View File
@@ -1,4 +1,10 @@
import { useRouteLoaderData } from 'react-router';
import { createContext, useRouteLoaderData } from 'react-router';
/**
* Per-request CSP nonce. Set by the root route middleware, read with
* `context.get(nonceContext)` in loaders/actions and `entry.server`.
*/
export const nonceContext = createContext<string>('');
/**
* Returns the supplied CSP nonce only when rendering on the server.
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.17.0"
"version": "2.18.0"
}
+3
View File
@@ -8,4 +8,7 @@ export default {
// kept without a trailing slash so they match exactly, and so the bare
// sub-path URL (e.g. "/ESign") still matches the basename at runtime.
basename: process.env.NEXT_PUBLIC_BASE_PATH ? process.env.NEXT_PUBLIC_BASE_PATH.replace(/\/$/, '') : '/',
future: {
v8_middleware: true,
},
} satisfies Config;
+2 -3
View File
@@ -24,12 +24,11 @@ export const appContext = async (c: Context, next: Next) => {
// These are non page paths like API.
if (!isPageRequest(request) || noSessionCookie || blacklistedPathsRegex.test(url.pathname)) {
return next();
return await next();
}
// Add context to any pages you want here.
return next();
return await next();
};
const setAppContext = (c: Context, context: AppContext) => {
+6 -23
View File
@@ -1,33 +1,16 @@
import { getContext } from 'hono/context-storage';
import type { AppLoadContext } from 'react-router';
import { RouterContextProvider } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
* Per-request CSP nonce set by `securityHeadersMiddleware`, read via
* `hono/context-storage` (enabled in `server/router.ts`).
*/
declare module 'react-router' {
interface AppLoadContext {
/**
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
* so it can be threaded into `<ServerRouter nonce>` and root loader
* data, which then feeds `<Scripts>`, `<Links>`, etc.
*/
nonce: string;
}
}
export const getRequestNonce = (): string => getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
/**
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
* production (`hono-react-router-adapter/node`).
*
* The Hono context isn't passed directly by the adapter, so we read it via
* `hono/context-storage`, which is enabled in `server/router.ts`.
* `future.v8_middleware` requires a `RouterContextProvider` instance here.
*/
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
export const getLoadContext = (): RouterContextProvider => new RouterContextProvider();
+2 -1
View File
@@ -5,6 +5,7 @@ import { lingui } from '@lingui/vite-plugin';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import serverAdapter from 'hono-react-router-adapter/vite';
import type { AppLoadContext } from 'react-router';
import tailwindcss from 'tailwindcss';
import { defineConfig, normalizePath } from 'vite';
import macrosPlugin from 'vite-plugin-babel-macros';
@@ -53,7 +54,7 @@ export default defineConfig({
entry: 'server/router.ts',
getLoadContext: async () => {
const { getLoadContext } = await import('./server/load-context');
return getLoadContext();
return getLoadContext() as unknown as AppLoadContext;
},
exclude: [
// Spread the defaults but replace the /.css$/ rule so that Bull
+1507 -3684
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.17.0",
"version": "2.18.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -46,6 +46,21 @@
"npm": ">=11.17.0",
"node": ">=24.0.0"
},
"allowScripts": {
"@documenso/skia-canvas": true,
"@playwright/browser-chromium": true,
"@prisma/client": true,
"@prisma/engines": true,
"aws-crt": true,
"esbuild": true,
"fsevents": true,
"inngest-cli": true,
"prisma": true,
"@datadog/pprof": false,
"core-js": false,
"msgpackr-extract": false,
"protobufjs": false
},
"devDependencies": {
"@biomejs/biome": "2.4.8",
"@types/react": "^19.2.17",
@@ -67,7 +82,7 @@
"esbuild": "^0.28.1",
"husky": "^9.1.7",
"inngest": "^3.54.0",
"inngest-cli": "^1.17.9",
"inngest-cli": "^1.44.0",
"lint-staged": "^16.2.7",
"nanoid": "^5.1.6",
"nodemailer": "^9.0.0",
@@ -130,7 +145,7 @@
"posthog-node": "4.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"sharp": "0.35.3",
"sharp": "0.35.4",
"typescript": "5.6.2",
"@marsidev/react-turnstile": "^1.5.0",
"zod": "^3.25.76"
@@ -0,0 +1,193 @@
import { createDocumentAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { waitForHydration } from '../fixtures/hydration';
import { signSignaturePad } from '../fixtures/signature';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
const SEEDED_PASSWORD = 'password';
const NEW_PASSWORD = 'Test123!';
/**
* Seed a document requiring PASSWORD action auth for a recipient with an account,
* and sign the recipient in on the signing page.
*
* Action auth is gated behind the cfr21 claim flag at write time only, so seeding
* the auth options directly bypasses the gate the same way action-auth.spec.ts does.
*/
const seedPasswordActionAuthDocument = async () => {
const { user: owner, team } = await seedUser();
const { user: recipient } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner,
teamId: team.id,
recipients: [recipient],
updateDocumentOptions: {
authOptions: createDocumentAuthOptions({
globalAccessAuth: [],
globalActionAuth: ['PASSWORD'],
}),
},
fields: [FieldType.SIGNATURE],
});
const { token, fields } = recipients[0];
const signatureField = fields.find((field) => field.type === FieldType.SIGNATURE);
if (!signatureField) {
throw new Error('Expected a signature field to be seeded');
}
return {
recipient,
signUrl: `/sign/${token}`,
signatureField,
};
};
test('[DOCUMENT_AUTH]: passwordless user is sent a setup link and can sign after setting a password', async ({
page,
}) => {
const { recipient, signUrl, signatureField } = await seedPasswordActionAuthDocument();
await apiSignin({
page,
email: recipient.email,
password: SEEDED_PASSWORD,
redirectPath: signUrl,
});
// Simulate an OAuth / passkey only account by removing the password after sign in.
await prisma.user.update({
where: { id: recipient.id },
data: { password: null },
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
await expect(page.getByText('Reauthentication is required to sign this field')).toBeVisible();
await expect(page.getByText('No password set')).toBeVisible();
// A bare session must not be able to set a password inline; it gets emailed a link instead.
await expect(page.getByLabel('New password')).not.toBeVisible();
await page.getByRole('button', { name: 'Send setup link' }).click();
await expect(page.getByText('Check your email')).toBeVisible();
const resetToken = await prisma.passwordResetToken.findFirstOrThrow({
where: { userId: recipient.id },
});
// Complete the emailed flow, which also invalidates all sessions.
await page.goto(`/reset-password/${resetToken.token}`);
// Filling controlled inputs before hydration gets reset by React.
await waitForHydration(page, 'input[name="password"]');
await page.getByLabel('Password', { exact: true }).fill(NEW_PASSWORD);
await page.getByLabel('Repeat Password').fill(NEW_PASSWORD);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
// Come back with the new password and the normal reauth form should now work.
await apiSignin({
page,
email: recipient.email,
password: NEW_PASSWORD,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('No password set')).not.toBeVisible();
await dialog.getByLabel('Password').fill(NEW_PASSWORD);
await dialog.getByRole('button', { name: 'Sign' }).click();
await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute('data-inserted', 'true');
});
test('[DOCUMENT_AUTH]: user with a password sees the normal password reauth form', async ({ page }) => {
const { recipient, signUrl, signatureField } = await seedPasswordActionAuthDocument();
await apiSignin({
page,
email: recipient.email,
password: SEEDED_PASSWORD,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
await expect(page.getByText('Reauthentication is required to sign this field')).toBeVisible();
await expect(page.getByText('No password set')).not.toBeVisible();
const dialog = page.getByRole('dialog');
// Wrong password is rejected.
await dialog.getByLabel('Password').fill('wrong-password');
await dialog.getByRole('button', { name: 'Sign' }).click();
await expect(dialog.getByText('Unauthorized')).toBeVisible();
// Correct password signs the field.
await dialog.getByLabel('Password').fill(SEEDED_PASSWORD);
await dialog.getByRole('button', { name: 'Sign' }).click();
await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute('data-inserted', 'true');
});
test('[DOCUMENT_AUTH]: passwordless user can request a setup link from security settings', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({
page,
email: user.email,
password: SEEDED_PASSWORD,
redirectPath: '/settings/profile',
});
await prisma.user.update({
where: { id: user.id },
data: { password: null },
});
await page.goto('/settings/security');
await expect(page.getByRole('heading', { name: 'Set a password' })).toBeVisible();
await expect(page.getByLabel('Current password')).not.toBeVisible();
await expect(page.getByLabel('New password')).not.toBeVisible();
// Clicking before hydration is a no-op, so retry until the sent state appears.
await expect(async () => {
await page.getByRole('button', { name: 'Send setup link' }).click();
await expect(page.getByRole('button', { name: 'Link sent' })).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 15_000 });
const resetToken = await prisma.passwordResetToken.findFirst({
where: { userId: user.id },
});
expect(resetToken).not.toBeNull();
});
@@ -0,0 +1,27 @@
import type { Page } from '@playwright/test';
/**
* Wait for React to hydrate the element matching the given selector.
*
* Filling controlled inputs before hydration is racy since React resets them
* to their default values once it takes over the DOM. React attaches internal
* fiber keys to DOM nodes during hydration, so their presence is a reliable
* signal that the element is interactive.
*/
export const waitForHydration = async (page: Page, selector: string, timeout = 15_000) => {
await page.waitForSelector(selector, { timeout });
await page.waitForFunction(
(sel) => {
const element = document.querySelector(sel);
if (!element) {
return false;
}
return Object.keys(element).some((key) => key.startsWith('__reactFiber'));
},
selector,
{ timeout },
);
};
@@ -0,0 +1,105 @@
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa';
import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { base32 } from '@scure/base';
import { generateHOTP } from 'oslo/otp';
import { apiSignin } from '../fixtures/authentication';
import { waitForHydration } from '../fixtures/hydration';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
/**
* Derive the current TOTP for a user the same way `verifyTwoFactorAuthenticationToken` does.
*/
const getCurrentTotpCode = async (userId: number) => {
const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
if (!DOCUMENSO_ENCRYPTION_KEY || !user.twoFactorSecret) {
throw new Error('Expected encryption key and 2FA secret');
}
const secret = Buffer.from(symmetricDecrypt({ key: DOCUMENSO_ENCRYPTION_KEY, data: user.twoFactorSecret })).toString(
'utf-8',
);
return await generateHOTP(base32.decode(secret), Math.floor(Date.now() / 30_000));
};
test('[USER] password update requires a 2FA code when 2FA is enabled', async ({ page }) => {
const oldPassword = 'password';
const newPassword = 'Test123!';
const { user } = await seedUser({ password: oldPassword });
// Sign in before enabling 2FA since apiSignin does not send a code.
await apiSignin({ page, email: user.email, password: oldPassword, redirectPath: '/settings/profile' });
await setupTwoFactorAuthentication({ user });
const userWithSecret = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
await enableTwoFactorAuthentication({ user: userWithSecret, code: await getCurrentTotpCode(user.id) });
await page.goto('/settings/security');
await waitForHydration(page, 'input[name="currentPassword"]');
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
await page.getByRole('button', { name: 'Update password' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Two-Factor Authentication')).toBeVisible();
// Empty code is caught client-side.
await dialog.getByRole('button', { name: 'Update password' }).click();
await expect(dialog.getByText('A code is required')).toBeVisible();
const codeInput = dialog.locator('input').first();
// Wrong code is rejected server-side and the dialog stays open.
await codeInput.fill('000000');
await dialog.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('The two factor code you provided is invalid');
await expect(dialog).toBeVisible();
// Correct code updates the password.
await codeInput.fill('');
await codeInput.fill(await getCurrentTotpCode(user.id));
await dialog.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('Password updated');
await expect(dialog).not.toBeVisible();
const updatedUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
expect(updatedUser.password).not.toBe(userWithSecret.password);
});
test('[USER] password update API rejects a missing 2FA code when 2FA is enabled', async ({ page }) => {
const { user } = await seedUser();
await apiSignin({ page, email: user.email, redirectPath: '/settings/profile' });
await setupTwoFactorAuthentication({ user });
const userWithSecret = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
await enableTwoFactorAuthentication({ user: userWithSecret, code: await getCurrentTotpCode(user.id) });
const response = await page.request.post('/api/auth/email-password/update-password', {
data: { currentPassword: 'password', password: 'Test123!' },
});
expect(response.status()).toBe(400);
expect(await response.json()).toMatchObject({ code: 'TWO_FACTOR_MISSING_CREDENTIALS' });
const unchangedUser = await prisma.user.findUniqueOrThrow({ where: { id: user.id } });
expect(unchangedUser.password).toBe(userWithSecret.password);
});
+17 -1
View File
@@ -21,6 +21,7 @@ import {
resendVerifyEmailRateLimit,
resetPasswordRateLimit,
signupRateLimit,
updatePasswordRateLimit,
verifyEmailRateLimit,
} from '@documenso/lib/server-only/rate-limit/rate-limits';
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
@@ -248,7 +249,7 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
* Update password endpoint.
*/
.post('/update-password', sValidator('json', ZUpdatePasswordSchema), async (c) => {
const { password, currentPassword } = c.req.valid('json');
const { password, currentPassword, totpCode, backupCode } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
if (!isSigninEnabledForProvider('email')) {
@@ -259,10 +260,25 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { session, user } = await getSession(c);
const updateLimitResult = await updatePasswordRateLimit.check({
ip: requestMetadata.ipAddress ?? 'unknown',
identifier: String(user.id),
});
const updateLimited = rateLimitResponse(c, updateLimitResult);
if (updateLimited) {
throw new HTTPException(429, {
res: updateLimited,
});
}
await updatePassword({
userId: user.id,
password,
currentPassword,
totpCode,
backupCode,
requestMetadata,
});
@@ -70,6 +70,8 @@ export type TResendVerifyEmailSchema = z.infer<typeof ZResendVerifyEmailSchema>;
export const ZUpdatePasswordSchema = z.object({
currentPassword: ZCurrentPasswordSchema,
password: ZPasswordSchema,
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});
export type TUpdatePasswordSchema = z.infer<typeof ZUpdatePasswordSchema>;
@@ -1,6 +1,7 @@
import { Plural, Trans } from '@lingui/react/macro';
import { Heading, Img, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
export type TemplateAccessAuth2FAProps = {
documentTitle: string;
@@ -18,13 +19,9 @@ export const TemplateAccessAuth2FA = ({
expiresInMinutes,
assetBaseUrl = 'http://localhost:3002',
}: TemplateAccessAuth2FAProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<div>
<Img src={getAssetUrl('/static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Img src={getEmailAssetUrl(assetBaseUrl, 'static/document.png')} alt="Document" className="mx-auto h-12 w-12" />
<Section className="mt-8">
<Heading className="text-center font-semibold text-foreground text-lg">
@@ -1,5 +1,6 @@
import { Img, Link } from '../components';
import { useBranding } from '../providers/branding';
import { getEmailAssetUrl } from '../utils/asset-url';
import { getSafeBrandingUrl } from '../utils/branding-url';
export type TemplateBrandingLogoProps = {
@@ -20,7 +21,7 @@ export const TemplateBrandingLogo = ({ assetBaseUrl, className = 'mb-4 h-6' }: T
const hasCustomBrandingLogo = branding.brandingEnabled && Boolean(branding.brandingLogo);
if (!hasCustomBrandingLogo) {
const documensoLogoUrl = new URL('/static/logo.png', assetBaseUrl).toString();
const documensoLogoUrl = getEmailAssetUrl(assetBaseUrl, 'static/logo.png');
return <Img src={documensoLogoUrl} alt="Documenso Logo" className={className} />;
}
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Button, Column, Img, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentCompletedProps {
@@ -16,10 +17,6 @@ export const TemplateDocumentCompleted = ({
assetBaseUrl,
customBody,
}: TemplateDocumentCompletedProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -29,7 +26,7 @@ export const TemplateDocumentCompleted = ({
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img
src={getAssetUrl('/static/completed.png')}
src={getEmailAssetUrl(assetBaseUrl, 'static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
@@ -51,7 +48,11 @@ export const TemplateDocumentCompleted = ({
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href={downloadLink}
>
<Img src={getAssetUrl('/static/download.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
<Img
src={getEmailAssetUrl(assetBaseUrl, 'static/download.png')}
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
alt=""
/>
<Trans>Download</Trans>
</Button>
</Section>
@@ -1,4 +1,5 @@
import { Column, Img, Row, Section } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
export interface TemplateDocumentImageProps {
assetBaseUrl: string;
@@ -6,17 +7,13 @@ export interface TemplateDocumentImageProps {
}
export const TemplateDocumentImage = ({ assetBaseUrl, className }: TemplateDocumentImageProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Section className={className}>
<Row className="table-fixed">
<Column />
<Column>
<Img className="mx-auto h-42" src={getAssetUrl('/static/document.png')} alt="Documenso" />
<Img className="mx-auto h-42" src={getEmailAssetUrl(assetBaseUrl, 'static/document.png')} alt="Documenso" />
</Column>
<Column />
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Column, Img, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentPendingProps {
@@ -9,10 +10,6 @@ export interface TemplateDocumentPendingProps {
}
export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: TemplateDocumentPendingProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -21,7 +18,11 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
<Section className="mb-4">
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img src={getAssetUrl('/static/clock.png')} className="-mt-0.5 mr-2 inline h-7 w-7 align-middle" alt="" />
<Img
src={getEmailAssetUrl(assetBaseUrl, 'static/clock.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
<Trans>Waiting for others</Trans>
</Text>
</Column>
@@ -1,6 +1,7 @@
import { Trans } from '@lingui/react/macro';
import { Column, Img, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentRecipientSignedProps {
@@ -16,10 +17,6 @@ export const TemplateDocumentRecipientSigned = ({
recipientEmail,
assetBaseUrl,
}: TemplateDocumentRecipientSignedProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
const recipientReference = recipientName || recipientEmail;
return (
@@ -31,7 +28,7 @@ export const TemplateDocumentRecipientSigned = ({
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img
src={getAssetUrl('/static/completed.png')}
src={getEmailAssetUrl(assetBaseUrl, 'static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
@@ -2,6 +2,7 @@ import { env } from '@documenso/lib/utils/env';
import { Trans } from '@lingui/react/macro';
import { Button, Column, Img, Link, Section, Text } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
import { TemplateDocumentImage } from './template-document-image';
export interface TemplateDocumentSelfSignedProps {
@@ -14,10 +15,6 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
const signUpUrl = `${NEXT_PUBLIC_WEBAPP_URL ?? 'http://localhost:3000'}/signup`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
@@ -27,7 +24,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
<Column align="center">
<Text className="font-semibold text-base text-foreground">
<Img
src={getAssetUrl('/static/completed.png')}
src={getEmailAssetUrl(assetBaseUrl, 'static/completed.png')}
className="-mt-0.5 mr-2 inline h-7 w-7 align-middle"
alt=""
/>
@@ -56,7 +53,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
>
<Img
src={getAssetUrl('/static/user-plus.png')}
src={getEmailAssetUrl(assetBaseUrl, 'static/user-plus.png')}
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
alt=""
/>
@@ -67,7 +64,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline"
href="https://documenso.com/pricing"
>
<Img src={getAssetUrl('/static/review.png')} className="mr-2 mb-0.5 inline h-5 w-5 align-middle" alt="" />
<Img
src={getEmailAssetUrl(assetBaseUrl, 'static/review.png')}
className="mr-2 mb-0.5 inline h-5 w-5 align-middle"
alt=""
/>
<Trans>View plans</Trans>
</Button>
</Section>
@@ -1,4 +1,5 @@
import { Img } from '../components';
import { getEmailAssetUrl } from '../utils/asset-url';
export interface TemplateImageProps {
assetBaseUrl: string;
@@ -7,11 +8,7 @@ export interface TemplateImageProps {
}
export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: TemplateImageProps) => {
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return <Img className={className} src={getAssetUrl(`/static/${staticAsset}`)} alt="" />;
return <Img className={className} src={getEmailAssetUrl(assetBaseUrl, `static/${staticAsset}`)} alt="" />;
};
export default TemplateImage;
@@ -5,6 +5,7 @@ import { Body, Container, Head, Html, Img, Preview, Section } from '../component
import type { TemplateAdminUserCreatedProps } from '../template-components/template-admin-user-created';
import { TemplateAdminUserCreated } from '../template-components/template-admin-user-created';
import { TemplateFooter } from '../template-components/template-footer';
import { getEmailAssetUrl } from '../utils/asset-url';
export const AdminUserCreatedTemplate = ({
resetPasswordLink,
@@ -14,10 +15,6 @@ export const AdminUserCreatedTemplate = ({
const previewText = msg`Set your password for Documenso`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
@@ -27,7 +24,7 @@ export const AdminUserCreatedTemplate = ({
<Section>
<Container className="mx-auto mt-8 mb-2 max-w-xl rounded-lg border border-border border-solid p-4 backdrop-blur-sm">
<Section>
<Img src={getAssetUrl('/static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
<Img src={getEmailAssetUrl(assetBaseUrl, 'static/logo.png')} alt="Documenso Logo" className="mb-4 h-6" />
<TemplateAdminUserCreated resetPasswordLink={resetPasswordLink} assetBaseUrl={assetBaseUrl} />
</Section>
+37 -14
View File
@@ -1,3 +1,4 @@
import type { TPasswordChangeSource } from '@documenso/lib/jobs/definitions/emails/send-password-reset-success-email';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@@ -8,16 +9,19 @@ import { TemplateFooter } from '../template-components/template-footer';
import type { TemplateResetPasswordProps } from '../template-components/template-reset-password';
import { TemplateResetPassword } from '../template-components/template-reset-password';
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps>;
export type ResetPasswordTemplateProps = Partial<TemplateResetPasswordProps> & {
source?: TPasswordChangeSource;
};
export const ResetPasswordTemplate = ({
userName = 'Lucas Smith',
userEmail = 'lucas@documenso.com',
assetBaseUrl = 'http://localhost:3002',
source = 'RESET',
}: ResetPasswordTemplateProps) => {
const { _ } = useLingui();
const previewText = msg`Password Reset Successful`;
const previewText = source === 'RESET' ? msg`Password Reset Successful` : msg`Your password was changed`;
return (
<Html>
@@ -46,18 +50,37 @@ export const ResetPasswordTemplate = ({
</Trans>
</Text>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>We've changed your password as you asked. You can now sign in with your new password.</Trans>
</Text>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>
Didn't request a password change? We are here to help you secure your account, just{' '}
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
contact us
</Link>
.
</Trans>
</Text>
{source === 'RESET' ? (
<>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>We've changed your password as you asked. You can now sign in with your new password.</Trans>
</Text>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>
Didn't request a password change? We are here to help you secure your account, just{' '}
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
contact us
</Link>
.
</Trans>
</Text>
</>
) : (
<>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>Your password was just changed from your account security settings.</Trans>
</Text>
<Text className="mt-2 text-base text-muted-foreground">
<Trans>
If this was you, no action is needed. If it wasn't, reset your password immediately and{' '}
<Link className="font-normal text-primary" href="mailto:hi@documenso.com">
contact us
</Link>
.
</Trans>
</Text>
</>
)}
</Section>
</Container>
+17
View File
@@ -0,0 +1,17 @@
/**
* Resolve a static email asset path against the asset base URL.
*
* The base is normalised to end with a trailing slash and the path is
* normalised to have no leading slash, so a sub-path in the base URL
* (e.g. "/ESign") is preserved. Passing a root-absolute path straight to
* `new URL()` would otherwise replace the base pathname entirely.
*
* `getEmailAssetUrl('https://host/ESign', 'static/logo.png')` -> `https://host/ESign/static/logo.png`
* `getEmailAssetUrl('https://host/ESign/', '/static/logo.png')` -> `https://host/ESign/static/logo.png`
*/
export const getEmailAssetUrl = (assetBaseUrl: string, path: string): string => {
const base = assetBaseUrl.endsWith('/') ? assetBaseUrl : `${assetBaseUrl}/`;
const relativePath = path.startsWith('/') ? path.slice(1) : path;
return new URL(relativePath, base).toString();
};
@@ -0,0 +1,31 @@
import { authClient } from '@documenso/auth/client';
import { useMutation } from '@tanstack/react-query';
import { AppError } from '../../errors/app-error';
import { useSession } from '../providers/session';
export type UsePasswordSetupRequestOptions = {
onSuccess?: () => void;
onError?: (errorCode: string) => void;
};
/**
* Sends the signed in user the standard password reset email so they can set a
* password via a verified link, rather than letting a bare session mint one.
*/
export const usePasswordSetupRequest = ({ onSuccess, onError }: UsePasswordSetupRequestOptions = {}) => {
const { user } = useSession();
const { mutate, isPending, isSuccess, error } = useMutation({
mutationFn: async () => authClient.emailPassword.forgotPassword({ email: user.email }),
onSuccess,
onError: (err) => onError?.(AppError.parseError(err).code),
});
return {
requestSetupLink: () => mutate(),
isPending,
isSuccess,
errorCode: error ? AppError.parseError(error).code : null,
};
};
+7
View File
@@ -23,6 +23,13 @@ export enum AppErrorCode {
SCHEMA_FAILED = 'SCHEMA_FAILED',
TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS',
TWO_FACTOR_AUTH_FAILED = 'TWO_FACTOR_AUTH_FAILED',
TWO_FACTOR_SETUP_REQUIRED = 'TWO_FACTOR_SETUP_REQUIRED',
TWO_FACTOR_MISSING_SECRET = 'TWO_FACTOR_MISSING_SECRET',
TWO_FACTOR_MISSING_CREDENTIALS = 'TWO_FACTOR_MISSING_CREDENTIALS',
INCORRECT_TWO_FACTOR_CODE = 'INCORRECT_TWO_FACTOR_CODE',
NO_PASSWORD = 'NO_PASSWORD',
INCORRECT_PASSWORD = 'INCORRECT_PASSWORD',
SAME_PASSWORD = 'SAME_PASSWORD',
WEBHOOK_INVALID_REQUEST = 'WEBHOOK_INVALID_REQUEST',
ENVELOPE_DRAFT = 'ENVELOPE_DRAFT',
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
@@ -4,5 +4,6 @@ import type { TSendPasswordResetSuccessEmailJobDefinition } from './send-passwor
export const run = async ({ payload }: { payload: TSendPasswordResetSuccessEmailJobDefinition }) => {
await sendResetPassword({
userId: payload.userId,
source: payload.source ?? 'RESET',
});
};
@@ -4,8 +4,20 @@ import type { JobDefinition } from '../../client/_internal/job';
const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_ID = 'send.password.reset.success.email';
/**
* How the password came to be changed, so the email can say so.
*
* - RESET: via the emailed reset link, unauthenticated.
* - UPDATE: via account settings, while signed in.
*/
export const ZPasswordChangeSourceSchema = z.enum(['RESET', 'UPDATE']);
export type TPasswordChangeSource = z.infer<typeof ZPasswordChangeSourceSchema>;
const SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION_SCHEMA = z.object({
userId: z.number(),
// Optional so jobs queued before this field existed still run; treated as RESET.
source: ZPasswordChangeSourceSchema.optional(),
});
export type TSendPasswordResetSuccessEmailJobDefinition = z.infer<
@@ -2,12 +2,10 @@ import { BulkSendCompleteEmail } from '@documenso/email/templates/bulk-send-comp
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { zEmail } from '@documenso/lib/utils/zod';
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/macro';
import { parse } from 'csv-parse/sync';
import { createElement } from 'react';
import { z } from 'zod';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
@@ -17,14 +15,6 @@ import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TBulkSendTemplateJobDefinition } from './bulk-send-template';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefinition; io: JobRunIO }) => {
const { userId, teamId, templateId, csvContent, sendImmediately, requestMetadata } = payload;
@@ -41,25 +31,21 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
throw new Error('Template not found');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rows = parse<any>(csvContent, { columns: true, skip_empty_lines: true });
if (rows.length > 100) {
throw new Error('Maximum 100 rows allowed per upload');
}
const { recipients } = template;
// Validate CSV structure
const csvHeaders = Object.keys(rows[0]);
const requiredHeaders = recipients.map((_, index) => `recipient_${index + 1}_email`);
// The CSV is validated upfront when the bulk send is uploaded, this acts as
// a final safeguard prior to processing.
const csvValidationResult = validateBulkSendCsv({
csvContent,
recipientCount: recipients.length,
});
for (const header of requiredHeaders) {
if (!csvHeaders.includes(header)) {
throw new Error(`Missing required column: ${header}`);
}
if (!csvValidationResult.success) {
throw new Error(`Bulk send CSV failed validation: ${JSON.stringify(csvValidationResult.error)}`);
}
const rows = csvValidationResult.data;
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -79,22 +65,6 @@ export const run = async ({ payload, io }: { payload: TBulkSendTemplateJobDefini
// Process each row
for (const [rowIndex, row] of rows.entries()) {
try {
for (const [recipientIndex] of recipients.entries()) {
const nameKey = `recipient_${recipientIndex + 1}_name`;
const emailKey = `recipient_${recipientIndex + 1}_email`;
const parsed = ZRecipientRowSchema.safeParse({
name: row[nameKey],
email: row[emailKey],
});
if (!parsed.success) {
throw new Error(
`Invalid recipient data provided for ${emailKey}, ${nameKey}: ${parsed.error.issues?.[0]?.message}`,
);
}
}
const envelope = await io.runTask(`create-document-${rowIndex}`, async () => {
return await createDocumentFromTemplate({
id: {
+2 -2
View File
@@ -46,7 +46,7 @@
"ai": "^7.0.58",
"bullmq": "^5.71.1",
"colord": "^2.9.3",
"csv-parse": "^6.1.0",
"csv-parse": "^7.0.2",
"inngest": "^3.54.0",
"ioredis": "^5.10.1",
"jose": "^6.1.2",
@@ -67,7 +67,7 @@
"posthog-node": "4.18.0",
"react": "^19.2.7",
"remeda": "^2.32.0",
"sharp": "0.35.3",
"sharp": "0.35.4",
"stripe": "^12.18.0",
"ts-pattern": "^5.9.0",
"zod": "^3.25.76"
+1 -1
View File
@@ -32,7 +32,7 @@ export const disableTwoFactorAuthentication = async ({
}
if (!isValid) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
await prisma.$transaction(async (tx) => {
+3 -3
View File
@@ -1,7 +1,7 @@
import { prisma } from '@documenso/prisma';
import { type User, UserSecurityAuditLogType } from '@prisma/client';
import { AppError } from '../../errors/app-error';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getBackupCodes } from './get-backup-code';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
@@ -22,13 +22,13 @@ export const enableTwoFactorAuthentication = async ({
}
if (!user.twoFactorSecret) {
throw new AppError('TWO_FACTOR_SETUP_REQUIRED');
throw new AppError(AppErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code });
if (!isValidToken) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
let recoveryCodes: string[] = [];
+4 -4
View File
@@ -1,6 +1,6 @@
import type { User } from '@prisma/client';
import { AppError } from '../../errors/app-error';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';
import { verifyBackupCode } from './verify-backup-code';
@@ -16,11 +16,11 @@ export const validateTwoFactorAuthentication = async ({
user,
}: ValidateTwoFactorAuthenticationOptions) => {
if (!user.twoFactorEnabled) {
throw new AppError('TWO_FACTOR_SETUP_REQUIRED');
throw new AppError(AppErrorCode.TWO_FACTOR_SETUP_REQUIRED);
}
if (!user.twoFactorSecret) {
throw new AppError('TWO_FACTOR_MISSING_SECRET');
throw new AppError(AppErrorCode.TWO_FACTOR_MISSING_SECRET);
}
if (totpCode) {
@@ -31,5 +31,5 @@ export const validateTwoFactorAuthentication = async ({
return verifyBackupCode({ user, backupCode });
}
throw new AppError('TWO_FACTOR_MISSING_CREDENTIALS');
throw new AppError(AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS);
};
@@ -1,6 +1,6 @@
import type { User } from '@prisma/client';
import { AppError } from '../../errors/app-error';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getBackupCodes } from './get-backup-code';
import { validateTwoFactorAuthentication } from './validate-2fa';
@@ -17,7 +17,7 @@ export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) =
}
if (!isValid) {
throw new AppError('INCORRECT_TWO_FACTOR_CODE');
throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE);
}
const backupCodes = getBackupCodes({ user });
@@ -1,17 +1,22 @@
import { mailer } from '@documenso/email/mailer';
import { ResetPasswordTemplate } from '@documenso/email/templates/reset-password';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { createElement } from 'react';
import { match } from 'ts-pattern';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import type { TPasswordChangeSource } from '../../jobs/definitions/emails/send-password-reset-success-email';
import { env } from '../../utils/env';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
export interface SendResetPasswordOptions {
userId: number;
source: TPasswordChangeSource;
}
export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) => {
export const sendResetPassword = async ({ userId, source }: SendResetPasswordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -24,6 +29,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
assetBaseUrl,
userEmail: user.email,
userName: user.name || '',
source,
});
const [html, text] = await Promise.all([
@@ -31,6 +37,13 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
renderEmailWithI18N(template, { plainText: true }),
]);
const i18n = await getI18nInstance();
const subject = match(source)
.with('RESET', () => i18n._(msg`Password Reset Success!`))
.with('UPDATE', () => i18n._(msg`Your password was changed`))
.exhaustive();
return await mailer.sendMail({
to: {
address: user.email,
@@ -40,7 +53,7 @@ export const sendResetPassword = async ({ userId }: SendResetPasswordOptions) =>
name: env('NEXT_PRIVATE_SMTP_FROM_NAME') || 'Documenso',
address: env('NEXT_PRIVATE_SMTP_FROM_ADDRESS') || 'noreply@documenso.com',
},
subject: 'Password Reset Success!',
subject,
html,
text,
});
@@ -66,6 +66,18 @@ export const linkOrgAccountRateLimit = createRateLimit({
window: '1h',
});
// ---- Auth (Tier 3 - Authenticated, verifies secrets) ----
/**
* Bounds guessing of the current password and 2FA code via the update password endpoint.
*/
export const updatePasswordRateLimit = createRateLimit({
action: 'auth.update-password',
max: 5,
globalMax: 20,
window: '15m',
});
export const reportSenderRateLimit = createRateLimit({
action: 'recipient.report-sender',
max: 1,
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest';
import type { TBulkSendCsvError, TValidateBulkSendCsvResult } from './validate-bulk-send-csv';
import { validateBulkSendCsv } from './validate-bulk-send-csv';
const buildCsv = (headers: string[], rows: string[][]) =>
[headers.join(','), ...rows.map((row) => row.join(','))].join('\n');
const expectFailure = (result: TValidateBulkSendCsvResult): TBulkSendCsvError => {
if (result.success) {
throw new Error('Expected validation to fail, but it passed');
}
return result.error;
};
describe('validateBulkSendCsv', () => {
describe('valid CSVs', () => {
it('returns the parsed rows for a valid CSV', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_1_name'],
[
['alice@example.com', 'Alice'],
['bob@example.com', 'Bob'],
],
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result).toEqual({
success: true,
data: [
{ recipient_1_email: 'alice@example.com', recipient_1_name: 'Alice' },
{ recipient_1_email: 'bob@example.com', recipient_1_name: 'Bob' },
],
});
});
it('allows an empty string email so template defaults can be used', () => {
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], [['', 'Alice']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('allows the optional name column to be omitted entirely', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('allows unknown extra columns', () => {
const csvContent = buildCsv(['recipient_1_email', 'unrelated_column'], [['alice@example.com', 'anything']]);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
it('validates columns for every configured recipient', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_2_email'],
[['alice@example.com', 'bob@example.com']],
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 2 });
expect(result.success).toBe(true);
});
it('allows exactly the maximum number of rows', () => {
const csvContent = buildCsv(
['recipient_1_email'],
Array.from({ length: 100 }, (_, index) => [`user${index}@example.com`]),
);
const result = validateBulkSendCsv({ csvContent, recipientCount: 1 });
expect(result.success).toBe(true);
});
});
describe('PARSE_ERROR', () => {
it('rejects a CSV that cannot be parsed', () => {
const csvContent = 'recipient_1_email\n"unclosed quote';
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'PARSE_ERROR' });
});
it('rejects a CSV with inconsistent column counts', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_1_name'],
[['alice@example.com', 'Alice', 'unexpected-extra-value']],
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'PARSE_ERROR' });
});
});
describe('EMPTY', () => {
it('rejects an empty file', () => {
const error = expectFailure(validateBulkSendCsv({ csvContent: '', recipientCount: 1 }));
expect(error).toEqual({ type: 'EMPTY' });
});
it('rejects a CSV containing only a header row', () => {
const csvContent = buildCsv(['recipient_1_email', 'recipient_1_name'], []);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'EMPTY' });
});
});
describe('ROW_LIMIT_EXCEEDED', () => {
it('rejects a CSV exceeding the default limit of 100 rows', () => {
const csvContent = buildCsv(
['recipient_1_email'],
Array.from({ length: 101 }, (_, index) => [`user${index}@example.com`]),
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 101, maxRows: 100 });
});
it('respects a custom maxRows option', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['bob@example.com']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1, maxRows: 1 }));
expect(error).toEqual({ type: 'ROW_LIMIT_EXCEEDED', rowCount: 2, maxRows: 1 });
});
});
describe('MISSING_COLUMNS', () => {
it('rejects a CSV missing a required email column', () => {
const csvContent = buildCsv(['recipient_1_name'], [['Alice']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toEqual({ type: 'MISSING_COLUMNS', missingColumns: ['recipient_1_email'] });
});
it('reports every missing column', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 3 }));
expect(error).toEqual({
type: 'MISSING_COLUMNS',
missingColumns: ['recipient_2_email', 'recipient_3_email'],
});
});
});
describe('INVALID_RECIPIENTS', () => {
it('rejects a CSV containing an invalid email', () => {
const csvContent = buildCsv(['recipient_1_email'], [['not-an-email']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [{ row: 1, column: 'recipient_1_email' }],
});
});
it('references the offending row and column', () => {
const csvContent = buildCsv(['recipient_1_email'], [['alice@example.com'], ['not-an-email']]);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 1 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [{ row: 2, column: 'recipient_1_email' }],
});
});
it('aggregates errors across multiple rows and recipients', () => {
const csvContent = buildCsv(
['recipient_1_email', 'recipient_2_email'],
[
['not-an-email', 'bob@example.com'],
['alice@example.com', 'also-not-an-email'],
],
);
const error = expectFailure(validateBulkSendCsv({ csvContent, recipientCount: 2 }));
expect(error).toMatchObject({
type: 'INVALID_RECIPIENTS',
rowErrors: [
{ row: 1, column: 'recipient_1_email' },
{ row: 2, column: 'recipient_2_email' },
],
});
});
});
});
@@ -0,0 +1,117 @@
import { parse } from 'csv-parse/sync';
import { z } from 'zod';
import { zEmail } from '../../utils/zod';
const ZRecipientRowSchema = z.object({
name: z.string().optional(),
email: z.union([
zEmail('Value must be a valid email or empty string'),
z.string().max(0, { message: 'Value must be a valid email or empty string' }),
]),
});
export type TBulkSendCsvRow = Record<string, string | undefined>;
export type TBulkSendCsvRowError = {
/**
* The 1-indexed row number the error occurred on, excluding the header row.
*/
row: number;
/**
* The column the error occurred in, such as `recipient_1_email`.
*/
column: string;
message: string;
};
export type TBulkSendCsvError =
| { type: 'PARSE_ERROR' }
| { type: 'EMPTY' }
| { type: 'ROW_LIMIT_EXCEEDED'; rowCount: number; maxRows: number }
| { type: 'MISSING_COLUMNS'; missingColumns: string[] }
| { type: 'INVALID_RECIPIENTS'; rowErrors: TBulkSendCsvRowError[] };
export type TValidateBulkSendCsvResult =
| { success: true; data: TBulkSendCsvRow[] }
| { success: false; error: TBulkSendCsvError };
export type ValidateBulkSendCsvOptions = {
csvContent: string;
/**
* The number of recipients configured on the template, used to derive the
* required `recipient_N_email` columns.
*/
recipientCount: number;
maxRows?: number;
};
/**
* Validate the CSV provided for a template bulk send.
*
* Returns a discriminated union so callers can surface structured error
* details, such as which rows contain invalid recipients.
*/
export const validateBulkSendCsv = ({
csvContent,
recipientCount,
maxRows = 100,
}: ValidateBulkSendCsvOptions): TValidateBulkSendCsvResult => {
let rows: TBulkSendCsvRow[];
try {
rows = parse(csvContent, { columns: true, skip_empty_lines: true });
} catch {
return { success: false, error: { type: 'PARSE_ERROR' } };
}
if (rows.length === 0) {
return { success: false, error: { type: 'EMPTY' } };
}
if (rows.length > maxRows) {
return { success: false, error: { type: 'ROW_LIMIT_EXCEEDED', rowCount: rows.length, maxRows } };
}
const csvHeaders = Object.keys(rows[0]);
const requiredHeaders = Array.from({ length: recipientCount }, (_, index) => `recipient_${index + 1}_email`);
const missingColumns = requiredHeaders.filter((header) => !csvHeaders.includes(header));
if (missingColumns.length > 0) {
return { success: false, error: { type: 'MISSING_COLUMNS', missingColumns } };
}
const rowErrors: TBulkSendCsvRowError[] = [];
for (const [rowIndex, row] of rows.entries()) {
for (let recipientIndex = 0; recipientIndex < recipientCount; recipientIndex += 1) {
const nameKey = `recipient_${recipientIndex + 1}_name`;
const emailKey = `recipient_${recipientIndex + 1}_email`;
const parsed = ZRecipientRowSchema.safeParse({
name: row[nameKey],
email: row[emailKey],
});
if (!parsed.success) {
rowErrors.push({
row: rowIndex + 1,
column: emailKey,
message: parsed.error.issues?.[0]?.message ?? 'Invalid value',
});
}
}
}
if (rowErrors.length > 0) {
return { success: false, error: { type: 'INVALID_RECIPIENTS', rowErrors } };
}
return { success: true, data: rows };
};
@@ -35,33 +35,6 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
},
});
// Todo: (RR7) Migrate to use this after RR7.
// Note: If we actually ever proceed with this, there are multiple
// locations where we will need to update this.
// const user = await prisma.$transaction(async (tx) => {
// const user = await tx.user.create({
// data: {
// name,
// email: email.toLowerCase(),
// password: hashedPassword, // Todo: (RR7) Drop password.
// signature,
// },
// });
// await tx.account.create({
// data: {
// userId: user.id,
// type: 'emailPassword', // Todo: (RR7)
// provider: 'DOCUMENSO', // Todo: (RR7) Enums
// providerAccountId: user.id.toString(),
// password: hashedPassword,
// },
// });
// return user;
// });
// Not used at the moment, uncomment if required.
await onCreateUserHook(user).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
@@ -0,0 +1,39 @@
import { prisma } from '@documenso/prisma';
import type { TUserAuthMethod } from '../../types/user-auth-method';
import { deriveUserAuthMethods } from '../../utils/user-auth-methods';
export type GetUserAuthMethodsOptions = {
userId: number;
};
/**
* Get the distinct sign in methods available to a user, such as password,
* passkey or linked OAuth providers.
*/
export const getUserAuthMethods = async ({ userId }: GetUserAuthMethodsOptions): Promise<TUserAuthMethod[]> => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
password: true,
accounts: {
select: {
provider: true,
},
},
_count: {
select: {
passkeys: true,
},
},
},
});
return deriveUserAuthMethods({
hasPassword: user.password !== null,
passkeyCount: user._count.passkeys,
accountProviders: user.accounts.map((account) => account.provider),
});
};
@@ -47,7 +47,7 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
const isSamePassword = await compare(password, foundToken.user.password || '');
if (isSamePassword) {
throw new AppError('SAME_PASSWORD');
throw new AppError(AppErrorCode.SAME_PASSWORD);
}
const hashedPassword = await hash(password, SALT_ROUNDS);
@@ -82,6 +82,7 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
name: 'send.password.reset.success.email',
payload: {
userId: foundToken.userId,
source: 'RESET',
},
});
@@ -4,41 +4,77 @@ import { prisma } from '@documenso/prisma';
import { compare, hash } from '@node-rs/bcrypt';
import { UserSecurityAuditLogType } from '@prisma/client';
import { AppError } from '../../errors/app-error';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobsClient } from '../../jobs/client';
import { validateTwoFactorAuthentication } from '../2fa/validate-2fa';
export type UpdatePasswordOptions = {
userId: number;
password: string;
currentPassword: string;
totpCode?: string;
backupCode?: string;
requestMetadata?: RequestMetadata;
};
export const updatePassword = async ({ userId, password, currentPassword, requestMetadata }: UpdatePasswordOptions) => {
// Existence check
/**
* Update the password for a user who already has one.
*
* Requires the current password, and a valid TOTP or backup code if the user
* has two factor authentication enabled.
*/
export const updatePassword = async ({
userId,
password,
currentPassword,
totpCode,
backupCode,
requestMetadata,
}: UpdatePasswordOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
select: {
id: true,
email: true,
password: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user.password) {
throw new AppError('NO_PASSWORD');
throw new AppError(AppErrorCode.NO_PASSWORD);
}
const isCurrentPasswordValid = await compare(currentPassword, user.password);
if (!isCurrentPasswordValid) {
throw new AppError('INCORRECT_PASSWORD');
throw new AppError(AppErrorCode.INCORRECT_PASSWORD);
}
if (user.twoFactorEnabled) {
if (!totpCode && !backupCode) {
throw new AppError(AppErrorCode.TWO_FACTOR_MISSING_CREDENTIALS, { statusCode: 400 });
}
const isTwoFactorValid = await validateTwoFactorAuthentication({ user, totpCode, backupCode });
if (!isTwoFactorValid) {
throw new AppError(AppErrorCode.INCORRECT_TWO_FACTOR_CODE, { statusCode: 401 });
}
}
// Compare the new password with the old password
const isSamePassword = await compare(password, user.password);
if (isSamePassword) {
throw new AppError('SAME_PASSWORD');
throw new AppError(AppErrorCode.SAME_PASSWORD);
}
const hashedNewPassword = await hash(password, SALT_ROUNDS);
return await prisma.$transaction(async (tx) => {
const updatedUser = await prisma.$transaction(async (tx) => {
await tx.userSecurityAuditLog.create({
data: {
userId,
@@ -63,4 +99,15 @@ export const updatePassword = async ({ userId, password, currentPassword, reques
},
});
});
// Notify the user so a change made from a hijacked session does not go unnoticed.
await jobsClient.triggerJob({
name: 'send.password.reset.success.email',
payload: {
userId,
source: 'UPDATE',
},
});
return updatedUser;
};
+17
View File
@@ -0,0 +1,17 @@
import { z } from 'zod';
/**
* The methods a user can use to sign in to their account.
*/
export const UserAuthMethod = {
PASSWORD: 'PASSWORD',
PASSKEY: 'PASSKEY',
GOOGLE: 'GOOGLE',
MICROSOFT: 'MICROSOFT',
OIDC: 'OIDC',
ORGANISATION_SSO: 'ORGANISATION_SSO',
} as const;
export const ZUserAuthMethodSchema = z.nativeEnum(UserAuthMethod);
export type TUserAuthMethod = z.infer<typeof ZUserAuthMethodSchema>;
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { UserAuthMethod } from '../types/user-auth-method';
import { deriveUserAuthMethods } from './user-auth-methods';
describe('deriveUserAuthMethods', () => {
it('returns an empty list when the user has no sign in methods', () => {
expect(deriveUserAuthMethods({ hasPassword: false, passkeyCount: 0, accountProviders: [] })).toEqual([]);
});
it('includes PASSWORD when the user has a password', () => {
expect(deriveUserAuthMethods({ hasPassword: true, passkeyCount: 0, accountProviders: [] })).toEqual([
UserAuthMethod.PASSWORD,
]);
});
it('includes PASSKEY when the user has at least one passkey', () => {
expect(deriveUserAuthMethods({ hasPassword: false, passkeyCount: 2, accountProviders: [] })).toEqual([
UserAuthMethod.PASSKEY,
]);
});
it('maps built in OAuth providers to their auth method', () => {
expect(
deriveUserAuthMethods({
hasPassword: false,
passkeyCount: 0,
accountProviders: ['google', 'microsoft', 'oidc'],
}),
).toEqual([UserAuthMethod.GOOGLE, UserAuthMethod.MICROSOFT, UserAuthMethod.OIDC]);
});
it('treats unknown providers as organisation SSO', () => {
expect(
deriveUserAuthMethods({
hasPassword: false,
passkeyCount: 0,
accountProviders: ['org_abc123'],
}),
).toEqual([UserAuthMethod.ORGANISATION_SSO]);
});
it('deduplicates repeated providers', () => {
expect(
deriveUserAuthMethods({
hasPassword: true,
passkeyCount: 0,
accountProviders: ['google', 'google', 'org_a', 'org_b'],
}),
).toEqual([UserAuthMethod.PASSWORD, UserAuthMethod.GOOGLE, UserAuthMethod.ORGANISATION_SSO]);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { type TUserAuthMethod, UserAuthMethod } from '../types/user-auth-method';
type DeriveUserAuthMethodsOptions = {
/**
* Whether the user has a password hash stored.
*/
hasPassword: boolean;
/**
* The number of passkeys registered to the user.
*/
passkeyCount: number;
/**
* The `provider` values of the user's linked `Account` rows.
*/
accountProviders: string[];
};
const OAUTH_PROVIDER_AUTH_METHODS: Record<string, TUserAuthMethod> = {
google: UserAuthMethod.GOOGLE,
microsoft: UserAuthMethod.MICROSOFT,
oidc: UserAuthMethod.OIDC,
};
/**
* Derive the distinct set of sign in methods available to a user.
*
* Any `Account.provider` value that is not one of the built in OAuth providers
* is treated as an organisation authentication portal, since those accounts use
* the organisation ID as the provider.
*/
export const deriveUserAuthMethods = ({
hasPassword,
passkeyCount,
accountProviders,
}: DeriveUserAuthMethodsOptions): TUserAuthMethod[] => {
const authMethods = new Set<TUserAuthMethod>();
if (hasPassword) {
authMethods.add(UserAuthMethod.PASSWORD);
}
if (passkeyCount > 0) {
authMethods.add(UserAuthMethod.PASSKEY);
}
for (const provider of accountProviders) {
authMethods.add(OAUTH_PROVIDER_AUTH_METHODS[provider] ?? UserAuthMethod.ORGANISATION_SSO);
}
return Array.from(authMethods);
};
@@ -0,0 +1,19 @@
import { getUserAuthMethods } from '@documenso/lib/server-only/user/get-user-auth-methods';
import { authenticatedProcedure } from '../trpc';
import { ZGetAuthMethodsResponseSchema } from './get-auth-methods.types';
/**
* Get the sign in methods available to the current user.
*/
export const getAuthMethodsRoute = authenticatedProcedure
.output(ZGetAuthMethodsResponseSchema)
.query(async ({ ctx }) => {
const authMethods = await getUserAuthMethods({
userId: ctx.user.id,
});
return {
authMethods,
};
});
@@ -0,0 +1,8 @@
import { ZUserAuthMethodSchema } from '@documenso/lib/types/user-auth-method';
import { z } from 'zod';
export const ZGetAuthMethodsResponseSchema = z.object({
authMethods: z.array(ZUserAuthMethodSchema),
});
export type TGetAuthMethodsResponse = z.infer<typeof ZGetAuthMethodsResponseSchema>;
@@ -5,9 +5,11 @@ import { createPasskeyRegistrationOptionsRoute } from './create-passkey-registra
import { createPasskeySigninOptionsRoute } from './create-passkey-signin-options';
import { deletePasskeyRoute } from './delete-passkey';
import { findPasskeysRoute } from './find-passkeys';
import { getAuthMethodsRoute } from './get-auth-methods';
import { updatePasskeyRoute } from './update-passkey';
export const authRouter = router({
getAuthMethods: getAuthMethodsRoute,
passkey: router({
create: createPasskeyRoute,
createAuthenticationOptions: createPasskeyAuthenticationOptionsRoute,
@@ -8,7 +8,7 @@ export const findTeamsRoute = authenticatedProcedure
.input(ZFindTeamsRequestSchema)
.output(ZFindTeamsResponseSchema)
.query(async ({ ctx, input }) => {
const { organisationId } = input;
const { organisationId, query, page, perPage } = input;
const { user } = ctx;
ctx.logger.info({
@@ -17,5 +17,5 @@ export const findTeamsRoute = authenticatedProcedure
},
});
return findTeams({ userId: user.id, organisationId });
return findTeams({ userId: user.id, organisationId, query, page, perPage });
});
+11 -1
View File
@@ -22,6 +22,7 @@ import { findTemplates } from '@documenso/lib/server-only/template/find-template
import { getOrganisationTemplateById } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id';
import { toggleTemplateDirectLink } from '@documenso/lib/server-only/template/toggle-template-direct-link';
import { validateBulkSendCsv } from '@documenso/lib/server-only/template/validate-bulk-send-csv';
import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
@@ -879,6 +880,15 @@ export const templateRouter = router({
});
}
const csvValidationResult = validateBulkSendCsv({
csvContent: csv,
recipientCount: template.recipients.length,
});
if (!csvValidationResult.success) {
return { success: false as const, error: csvValidationResult.error };
}
await jobs.triggerJob({
name: 'internal.bulk-send-template',
payload: {
@@ -891,6 +901,6 @@ export const templateRouter = router({
},
});
return { success: true };
return { success: true as const };
}),
});
-143
View File
@@ -1,143 +0,0 @@
import { useLayoutEffect, useRef } from 'react';
import { cn } from '../lib/utils';
export type Dimensions = {
height: number;
width: number;
};
export type AutoSizedTextProps = {
children: React.ReactNode;
className?: string;
maxHeight?: number;
useRem?: boolean;
};
const ITERATION_LIMIT = 20;
const MAXIMUM_DIFFERENCE = 1; // px
function getElementDimensions(element: HTMLElement): Dimensions {
const bbox = element.getBoundingClientRect();
return {
width: bbox.width,
height: bbox.height,
};
}
function getBaseFontSize(): number {
try {
const fontSize = getComputedStyle(document.documentElement).fontSize;
const parsed = parseFloat(fontSize);
// Check if we got a valid number
if (!Number.isFinite(parsed)) {
return 16;
}
return parsed;
} catch (error) {
// Fallback to browser default if anything goes wrong
return 16;
}
}
function pxToRem(px: number): number {
return px / getBaseFontSize();
}
export function AutoSizedText({ children, className, maxHeight, useRem = false }: AutoSizedTextProps) {
const childRef = useRef<HTMLDivElement>(null);
const fontSize = useRef<number>(0);
const fontSizeLowerBound = useRef<number>(0);
const fontSizeUpperBound = useRef<number>(0);
const adjustFontSize = (childDimensions: Dimensions, parentDimensions: Dimensions) => {
const childElement = childRef.current;
if (!childElement) {
return;
}
let newFontSize: number;
const targetHeight = maxHeight && maxHeight < parentDimensions.height ? maxHeight : parentDimensions.height;
const isElementTooBig = childDimensions.width > parentDimensions.width || childDimensions.height > targetHeight;
if (isElementTooBig) {
// Scale down if element is bigger than target
newFontSize = (fontSizeLowerBound.current + fontSize.current) / 2;
fontSizeUpperBound.current = fontSize.current;
} else if (childDimensions.width < parentDimensions.width || childDimensions.height < parentDimensions.height) {
// Scale up if element is smaller than target
newFontSize = (fontSizeUpperBound.current + fontSize.current) / 2;
fontSizeLowerBound.current = fontSize.current;
}
fontSize.current = newFontSize;
// Convert to rem if useRem is true
const displayFontSize = useRem ? `${pxToRem(newFontSize)}rem` : `${newFontSize}px`;
childElement.style.fontSize = displayFontSize;
};
useLayoutEffect(() => {
const childElement = childRef.current;
const parentElement = childRef.current?.parentElement;
if (!childElement || !parentElement) {
return;
}
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
const parentDimensions = entry.contentRect;
// Reset iteration parameters
fontSizeLowerBound.current = 0;
fontSizeUpperBound.current = parentDimensions.height;
let iterationCount = 0;
while (iterationCount <= ITERATION_LIMIT) {
const childDimensions = getElementDimensions(childElement);
const targetHeight = maxHeight && maxHeight < parentDimensions.height ? maxHeight : parentDimensions.height;
const widthDifference = parentDimensions.width - childDimensions.width;
const heightDifference = targetHeight - childDimensions.height;
const childFitsIntoParent = heightDifference >= 0 && widthDifference >= 0;
const isWithinTolerance =
Math.abs(widthDifference) <= MAXIMUM_DIFFERENCE || Math.abs(heightDifference) <= MAXIMUM_DIFFERENCE;
if (childFitsIntoParent && isWithinTolerance) {
break;
}
adjustFontSize(childDimensions, parentDimensions);
iterationCount += 1;
}
});
observer.observe(parentElement);
return () => {
observer.disconnect();
};
}, [maxHeight, useRem]);
return (
<div ref={childRef} className={cn('inline-block leading-none', className)}>
{children}
</div>
);
}
+9 -5
View File
@@ -87,10 +87,11 @@ export const DocumentDropzone = ({
{...getRootProps()}
{...props}
>
<CardContent className="flex flex-col items-center justify-center p-6 text-muted-foreground/40">
<CardContent className="flex flex-col items-center justify-center px-4 py-2 text-muted-foreground/40 sm:p-6">
{/* Below `sm` the illustration is hidden and the dropzone is sized like a regular button. */}
{disabled ? (
// Disabled State
<div className="flex">
<div className="hidden sm:flex">
<motion.div
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-destructive/10 group-hover:bg-destructive/2"
variants={DocumentDropzoneDisabledCardLeftVariants}
@@ -119,7 +120,7 @@ export const DocumentDropzone = ({
</div>
) : (
// Non Disabled State
<div className="flex">
<div className="hidden sm:flex">
<motion.div
className="a z-10 flex aspect-[3/4] w-24 origin-top-right -rotate-[22deg] flex-col gap-y-1 rounded-lg border border-muted-foreground/20 bg-background/80 px-2 py-4 backdrop-blur-sm group-hover:border-documenso/80"
variants={DocumentDropzoneCardLeftVariants}
@@ -147,9 +148,12 @@ export const DocumentDropzone = ({
<input {...getInputProps()} />
<p className="mt-6 font-medium text-foreground">{_(heading[type])}</p>
<p className="flex items-center justify-center gap-2 text-center font-medium text-foreground text-sm sm:mt-6 sm:text-base">
{!disabled && <Plus className="h-4 w-4 sm:hidden" aria-hidden />}
{_(heading[type])}
</p>
<p className="mt-1 text-center text-muted-foreground/80 text-sm">
<p className={cn('mt-1 text-center text-muted-foreground/80 text-sm', !disabled && 'hidden sm:block')}>
{_(disabled ? disabledMessage : msg`Drag & drop your document here.`)}
</p>
@@ -68,6 +68,12 @@ export type AddSignersFormProps = {
onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean;
/**
* CSP nonce forwarded to `@hello-pangea/dnd`, which injects its drag styles
* as runtime `<style>` elements that would otherwise be blocked by the
* strict `style-src-elem` policy.
*/
nonce?: string;
};
export const AddSignersFormPartial = ({
@@ -79,6 +85,7 @@ export const AddSignersFormPartial = ({
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
nonce,
}: AddSignersFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -647,6 +654,7 @@ export const AddSignersFormPartial = ({
/>
<DragDropContext
nonce={nonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -58,6 +58,12 @@ export type AddTemplatePlaceholderRecipientsFormProps = {
onSubmit: (_data: TAddTemplatePlacholderRecipientsFormSchema) => void;
onAutoSave: (_data: TAddTemplatePlacholderRecipientsFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean;
/**
* CSP nonce forwarded to `@hello-pangea/dnd`, which injects its drag styles
* as runtime `<style>` elements that would otherwise be blocked by the
* strict `style-src-elem` policy.
*/
nonce?: string;
};
export const AddTemplatePlaceholderRecipientsFormPartial = ({
@@ -70,6 +76,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
isDocumentPdfLoaded,
onSubmit,
onAutoSave,
nonce,
}: AddTemplatePlaceholderRecipientsFormProps) => {
const initialId = useId();
const $sensorApi = useRef<SensorAPI | null>(null);
@@ -525,6 +532,7 @@ export const AddTemplatePlaceholderRecipientsFormPartial = ({
{/* Drag and drop context */}
<DragDropContext
nonce={nonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {