Compare commits

...
Author SHA1 Message Date
Catalin Pit a39e034876 feat: hide the reject option when the document disallows rejection
Hides the reject dialog on the V2 signing page sidebar and mobile menu,
the embed V1 signing page and the multi-sign embed view when the
document's allowDocumentRejection is false. In embeds the reject option
is shown only when both the embed's allowDocumentRejection option and
the document setting allow it, and the embed V1 footer layout follows the
same condition. Exposes the field in the multi-sign document response so
the embed view can read it.

Documents the relationship between the embed's allowDocumentRejection
option and the document setting in the iframe and embedded editor docs,
and in the API description of the field.

Part of #2561
2026-09-14 16:45:27 +03:00
Catalin Pit 5ab94b86fd Merge branch 'main' into feat/org-team-allow-document-rejection-setting 2026-09-11 14:46:41 +03:00
Catalin Pit 2140d132d7 feat: persist and enforce the allow document rejection setting
Accepts allowDocumentRejection in the organisation and team settings
update routes, adds it to the default organisation (true) and team
(inherit) settings, and copies the merged value into the document meta
when a document or template is created via extractDerivedDocumentMeta.
Documents created from a template take the template's value, with an
optional override on envelope.use.

Exposes the field in the recipient signing response and refuses
rejectDocumentWithToken requests for documents that disallow rejection,
since the endpoint can be called directly without the UI.

Extends the document preferences and envelope settings e2e tests to
cover inheritance into the team settings and new documents, and
persistence of the per-document value from the editor.
2026-09-11 14:28:15 +03: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
Catalin Pit d91a3e3828 feat: add allow document rejection setting to document preferences
Adds an allowDocumentRejection column to organisation settings (default
true), team settings (nullable, inherits from the organisation) and
document meta (default true), with the migration.

Adds the "Allow Document Rejection" field to the organisation and team
document preferences form, wires it through the settings pages, and lists
it in the reset-to-defaults dialog and the admin settings section. Adds a
per-document Yes/No field to the envelope editor settings dialog.

Exposes the field on the document meta create/update schemas and the
document, envelope, template and editor envelope response schemas, and
hides the reject dialog on the V1 signing page when the document
disallows rejection.
2026-09-11 09:38:44 +03: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
95 changed files with 1574 additions and 411 deletions
@@ -186,6 +186,15 @@ Controls envelope configuration options. Set to `null` to hide envelope settings
| `allowConfigureEmailSender` | `boolean` | `true` | Allow configuring the email sender |
| `allowConfigureEmailReplyTo` | `boolean` | `true` | Allow configuring the email reply-to |
<Callout title="Allow Document Rejection">
The settings dialog also contains an "Allow Document Rejection" option that is always shown. It sets the document's
own rejection setting (`meta.allowDocumentRejection` in the API) and defaults to the organisation or team document
preference. This
is separate from the `allowDocumentRejection` option of the embedded signing views: that option only controls the
signing embed's UI, while this one is stored on the document and enforced by the server. A recipient can reject only
when both allow it.
</Callout>
### Actions
Controls available actions during editing:
@@ -47,10 +47,20 @@ Documenso expects the fragment to be **base64** of:
| `lockEmail` | `boolean` | Lock the email field (prevents editing). |
| `language` | `string` | Force the embed language (e.g. `en`). |
| `darkModeDisabled` | `boolean` | Disable dark mode behavior. |
| `allowDocumentRejection` | `boolean` | Allow or disallow document rejection. |
| `allowDocumentRejection` | `boolean` | Show the reject option in the embed. Defaults to `false`. The document must also allow rejection, see below. |
| `css` | `string` | Inject custom CSS into the embed. |
| `cssVars` | `object` | Override embed CSS variables (see the CSS Variables page). |
<Callout title="allowDocumentRejection and the document's own setting">
Two settings control whether a recipient can reject a document in an embed, and both must allow it:
- `allowDocumentRejection` above, which you pass per embed. It only controls your embed's UI and defaults to `false`.
- The document's own "Allow Document Rejection" setting. Its default comes from the organisation or team document
preferences, and it can be changed per document in the editor or through the API (`meta.allowDocumentRejection`).
When it is disabled, the reject option stays hidden even if you pass `allowDocumentRejection: true`, and the server
refuses rejection requests for that document.
</Callout>
#### Example
```ts
@@ -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
@@ -96,6 +96,9 @@ export const DocumentPreferencesResetDialog = ({
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Allow document rejection</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
@@ -18,7 +18,7 @@ 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';
@@ -271,7 +271,12 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc
))
.with({ type: 'ROW_LIMIT_EXCEEDED' }, ({ rowCount, maxRows }) => (
<Trans>
The CSV contains {rowCount} rows. A maximum of {maxRows} rows is allowed per upload.
<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 }) => (
@@ -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) => {
@@ -96,6 +96,9 @@ export const EmbedSignDocumentV1ClientPage = ({
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
// Both the embed host and the document settings must allow rejection.
const isDocumentRejectionAllowed = allowDocumentRejection && (metadata?.allowDocumentRejection ?? true);
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
@@ -303,7 +306,7 @@ export const EmbedSignDocumentV1ClientPage = ({
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
{allowDocumentRejection && (
{isDocumentRejectionAllowed && (
<DocumentSigningRejectDialog documentId={documentId} token={token} onRejected={onDocumentRejected} />
)}
</div>
@@ -502,7 +505,7 @@ export const EmbedSignDocumentV1ClientPage = ({
</Button>
) : (
<Button
className={allowDocumentRejection ? 'col-start-2' : 'col-span-2'}
className={isDocumentRejectionAllowed ? 'col-start-2' : 'col-span-2'}
disabled={isThrottled}
loading={isSubmitting}
onClick={() => throttledOnCompleteClick()}
@@ -228,7 +228,7 @@ export const MultiSignDocumentSigningView = ({
</Button>
</div>
{allowDocumentRejection && (
{allowDocumentRejection && (document.documentMeta?.allowDocumentRejection ?? true) && (
<div className="embed--Actions mt-8 mb-4 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog documentId={document.id} token={token} onRejected={onRejected} />
</div>
@@ -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>
);
};
@@ -43,6 +43,7 @@ export type TDocumentPreferencesFormSchema = {
signatureTypes: DocumentSignatureType[];
defaultRecipients: TDefaultRecipients | null;
delegateDocumentOwnership: boolean | null;
allowDocumentRejection: boolean | null;
aiFeaturesEnabled: boolean | null;
};
@@ -57,6 +58,7 @@ type SettingsSubset = Pick<
| 'drawSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'allowDocumentRejection'
| 'aiFeaturesEnabled'
>;
@@ -77,6 +79,7 @@ const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPr
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
allowDocumentRejection: settings.allowDocumentRejection,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
};
};
@@ -101,6 +104,7 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
}),
defaultRecipients: ZDefaultRecipientsSchema.nullable(),
delegateDocumentOwnership: z.boolean().nullable(),
allowDocumentRejection: z.boolean().nullable(),
aiFeaturesEnabled: z.boolean().nullable(),
});
@@ -486,6 +490,60 @@ export const DocumentPreferencesForm = ({ settings, onFormSubmit, canInherit }:
)}
/>
<FormField
control={form.control}
name="allowDocumentRejection"
render={({ field }) => (
<InheritableField
className="flex-1"
canInherit={canInherit}
isInherited={field.value === null}
label={<Trans>Allow Document Rejection</Trans>}
testId="allow-document-rejection"
>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger
className="bg-background text-muted-foreground"
data-testid="allow-document-rejection-trigger"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether recipients can reject newly created documents from the signing page. Existing
documents keep their current setting. You can change it per document in the document settings.
</Trans>
</FormDescription>
</InheritableField>
)}
/>
{isAiFeaturesConfigured && (
<FormField
control={form.control}
@@ -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>
);
};
@@ -138,6 +138,12 @@ export const AdminGlobalSettingsSection = ({
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Allow document rejection</Trans>}>
<DetailsValue>
{booleanValue(settings.allowDocumentRejection, inheritedSettings?.allowDocumentRejection)}
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Typed signature</Trans>}>
<DetailsValue>
{booleanValue(settings.typedSignatureEnabled, inheritedSettings?.typedSignatureEnabled)}
@@ -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>
);
};
@@ -261,7 +261,9 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex items-center gap-x-4">
<DocumentSigningAttachmentsPopover envelopeId={document.envelopeId} token={recipient.token} />
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
{documentMeta.allowDocumentRejection && (
<DocumentSigningRejectDialog documentId={document.id} token={recipient.token} />
)}
</div>
</div>
@@ -63,6 +63,10 @@ export const DocumentSigningPageViewV2 = () => {
onDocumentRejected,
} = useEmbedSigningContext() || {};
// Both the document settings and the embed host (if any) must allow rejection.
const isDocumentRejectionAllowed =
envelope.type === EnvelopeType.DOCUMENT && envelope.documentMeta.allowDocumentRejection && allowDocumentRejection;
const { t } = useLingui();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
@@ -193,7 +197,7 @@ export const DocumentSigningPageViewV2 = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
{isDocumentRejectionAllowed && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
@@ -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
@@ -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();
@@ -795,6 +797,7 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<DragDropContext
nonce={cspNonce}
onDragEnd={onDragEnd}
sensors={[
(api: SensorAPI) => {
@@ -110,6 +110,7 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
message: msg`At least one signature type must be enabled`.id,
}),
allowDocumentRejection: z.boolean(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
}),
@@ -200,6 +201,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
emailReplyTo: envelope.documentMeta.emailReplyTo ?? undefined,
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
allowDocumentRejection: envelope.documentMeta.allowDocumentRejection,
envelopeExpirationPeriod: envelope.documentMeta?.envelopeExpirationPeriod ?? null,
reminderSettings: envelope.documentMeta?.reminderSettings ?? null,
},
@@ -249,6 +251,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
message,
subject,
emailReplyTo,
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
} = data.meta;
@@ -278,6 +281,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
},
@@ -465,6 +469,52 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
/>
)}
<FormField
control={form.control}
name="meta.allowDocumentRejection"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Allow Document Rejection</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
Controls whether recipients can reject the document from the signing page.
</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
<FormControl>
<Select
value={field.value ? 'true' : 'false'}
onValueChange={(value) => field.onChange(value === 'true')}
>
<SelectTrigger className="bg-background" data-testid="allow-document-rejection-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{settings.allowConfigureDateFormat && (
<FormField
control={form.control}
@@ -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();
@@ -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
@@ -87,7 +87,11 @@ export const EnvelopeSignerHeader = () => {
const MobileDropdownMenu = () => {
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
const { allowDocumentRejection } = useEmbedSigningContext() || {};
const { allowDocumentRejection = true } = useEmbedSigningContext() || {};
// Both the document settings and the embed host (if any) must allow rejection.
const isDocumentRejectionAllowed =
envelope.type === EnvelopeType.DOCUMENT && envelope.documentMeta.allowDocumentRejection && allowDocumentRejection;
return (
<DropdownMenu>
@@ -113,7 +117,7 @@ const MobileDropdownMenu = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
{isDocumentRejectionAllowed && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
@@ -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
@@ -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"
/>
@@ -39,6 +39,7 @@ export default function OrganisationSettingsDocumentPage() {
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
} = data;
@@ -46,6 +47,7 @@ export default function OrganisationSettingsDocumentPage() {
documentVisibility === null ||
documentLanguage === null ||
documentDateFormat === null ||
allowDocumentRejection === null ||
aiFeaturesEnabled === null
) {
throw new Error('Should not be possible.');
@@ -63,6 +65,7 @@ export default function OrganisationSettingsDocumentPage() {
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
},
});
@@ -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>
@@ -33,6 +33,7 @@ export default function TeamsSettingsPage() {
signatureTypes,
defaultRecipients,
delegateDocumentOwnership,
allowDocumentRejection,
aiFeaturesEnabled,
} = data;
@@ -57,6 +58,7 @@ export default function TeamsSettingsPage() {
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
}),
delegateDocumentOwnership,
allowDocumentRejection,
},
});
@@ -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`}
/>
@@ -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();
});
@@ -24,6 +24,7 @@ const TEST_SETTINGS_VALUES = {
subject: 'E2E settings subject',
message: 'E2E settings message',
language: 'French',
allowDocumentRejection: 'No',
dateFormat: 'DD/MM/YYYY',
timezone: 'Europe/London',
distributionMethod: 'None',
@@ -104,6 +105,10 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
await root.getByRole('option', { name: 'Upload' }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Allow Document Rejection').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.allowDocumentRejection, exact: true }).click();
await clickSettingsDialogHeader(root);
await getComboboxByLabel(root, 'Date Format').click();
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.dateFormat, exact: true }).click();
await clickSettingsDialogHeader(root);
@@ -265,6 +270,9 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
await expect(root.locator('input[name="meta.redirectUrl"]')).toHaveValue(TEST_SETTINGS_VALUES.redirectUrl);
await expect(getComboboxByLabel(root, 'Language')).toContainText(TEST_SETTINGS_VALUES.language);
await expect(getComboboxByLabel(root, 'Allowed Signature Types')).not.toContainText('Upload');
await expect(getComboboxByLabel(root, 'Allow Document Rejection')).toContainText(
TEST_SETTINGS_VALUES.allowDocumentRejection,
);
await expect(getComboboxByLabel(root, 'Date Format')).toContainText(TEST_SETTINGS_VALUES.dateFormat);
await expect(getComboboxByLabel(root, 'Time Zone')).toContainText(TEST_SETTINGS_VALUES.timezone);
await expect(root.locator('[data-testid="documentDistributionMethodSelectValue"]')).toContainText(
@@ -384,6 +392,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.allowDocumentRejection).toBe(false);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
@@ -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 },
);
};
@@ -37,6 +37,10 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Upload' }).click();
await page.keyboard.press('Escape');
// Disable document rejection
await page.getByTestId('allow-document-rejection-trigger').click();
await page.getByRole('option', { name: 'No', exact: true }).click();
await page.getByRole('button', { name: 'Save changes' }).first().click();
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
@@ -68,10 +72,14 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.allowDocumentRejection).toEqual(false);
// Edit the team settings
await page.goto(`/t/${team.url}/settings/document`);
// Document rejection is left untouched so it keeps inheriting from the organisation.
await expect(page.getByTestId('allow-document-rejection-status')).toHaveText('Inherited');
await page.getByTestId('document-visibility-trigger').click();
await page.getByRole('option', { name: 'Everyone can access and view' }).click();
await page.getByTestId('document-language-trigger').click();
@@ -102,6 +110,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.allowDocumentRejection).toEqual(false);
const document = await seedTeamDocumentWithMeta(team);
@@ -120,6 +129,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
expect(documentMeta.allowDocumentRejection).toEqual(false);
});
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
@@ -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<
+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,
});
@@ -33,7 +33,11 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
envelope: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
},
include: {
envelope: true,
envelope: {
include: {
documentMeta: true,
},
},
},
});
@@ -51,6 +55,14 @@ export async function rejectDocumentWithToken({ token, id, reason, requestMetada
});
}
// Hiding the reject button on the signing page is not enough because the
// recipient can call this endpoint directly, so enforce the setting here.
if (!envelope.documentMeta.allowDocumentRejection) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} does not allow rejection`,
});
}
assertRecipientNotExpired(recipient);
// Update the recipient status to rejected
@@ -48,6 +48,7 @@ export const ZEnvelopeForSigningResponse = z.object({
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
allowDocumentRejection: true,
language: true,
}),
recipients: ZRecipientLiteSchema.pick({
@@ -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,
@@ -113,6 +113,7 @@ export type CreateDocumentFromTemplateOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
allowDocumentRejection?: boolean;
};
formValues?: TDocumentFormValues;
@@ -542,6 +543,7 @@ export const createDocumentFromTemplate = async ({
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
allowDocumentRejection: override?.allowDocumentRejection ?? template.documentMeta?.allowDocumentRejection,
},
signatureLevel,
),
@@ -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;
};
+8
View File
@@ -28,6 +28,7 @@ export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDocumentRejection: true,
language: true,
emailSettings: true,
});
@@ -105,6 +106,12 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using an uploaded signature.');
export const ZDocumentMetaAllowDocumentRejectionSchema = z
.boolean()
.describe(
'Whether recipients can reject the document from the signing page. Defaults to the organisation or team document preference. In embedded signing the embed must also enable its own allowDocumentRejection option.',
);
/**
* Note: Any updates to this will cause public API changes. You will need to update
* all corresponding areas where this is used (some places that use this needs to pass
@@ -123,6 +130,7 @@ export const ZDocumentMetaCreateSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
allowDocumentRejection: ZDocumentMetaAllowDocumentRejectionSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: zEmail().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
+1
View File
@@ -63,6 +63,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
allowDocumentRejection: true,
language: true,
emailSettings: true,
emailId: true,
+1
View File
@@ -280,6 +280,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
allowDocumentRejection: true,
language: true,
emailSettings: true,
emailId: true,
+1
View File
@@ -50,6 +50,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
allowDocumentRejection: true,
language: true,
emailSettings: true,
emailId: true,
+1
View File
@@ -55,6 +55,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
allowDocumentRejection: true,
distributionMethod: true,
redirectUrl: true,
language: true,
+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>;
+3
View File
@@ -70,6 +70,9 @@ export const extractDerivedDocumentMeta = (
// Reminder settings.
reminderSettings: meta.reminderSettings ?? settings.reminderSettings ?? null,
// Rejection settings.
allowDocumentRejection: meta.allowDocumentRejection ?? settings.allowDocumentRejection,
} satisfies Omit<DocumentMeta, 'id'>;
};
+1
View File
@@ -111,6 +111,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
documentTimezone: null, // Null means local timezone.
documentDateFormat: DEFAULT_DOCUMENT_DATE_FORMAT,
delegateDocumentOwnership: false,
allowDocumentRejection: true,
includeSenderDetails: true,
includeSigningCertificate: true,
+1
View File
@@ -178,6 +178,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
documentTimezone: null,
documentDateFormat: null,
delegateDocumentOwnership: null,
allowDocumentRejection: null,
includeSenderDetails: null,
includeSigningCertificate: null,
@@ -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,8 @@
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "allowDocumentRejection" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "allowDocumentRejection" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "allowDocumentRejection" BOOLEAN;
+4
View File
@@ -583,6 +583,8 @@ model DocumentMeta {
reminderSettings Json? /// [EnvelopeReminderSettings] @zod.custom.use(ZEnvelopeReminderSettingsSchema)
allowDocumentRejection Boolean @default(true)
envelope Envelope?
}
@@ -966,6 +968,7 @@ model OrganisationGlobalSettings {
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
delegateDocumentOwnership Boolean @default(false)
allowDocumentRejection Boolean @default(true)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
@@ -1005,6 +1008,7 @@ model TeamGlobalSettings {
documentTimezone String?
documentDateFormat String?
delegateDocumentOwnership Boolean?
allowDocumentRejection Boolean?
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
@@ -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,
@@ -31,6 +31,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
allowDocumentRejection: true,
language: true,
emailSettings: true,
}).nullable(),
@@ -2,6 +2,7 @@ import { ZEnvelopeExpirationPeriod } from '@documenso/lib/constants/envelope-exp
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { ZDocumentFormValuesSchema } from '@documenso/lib/types/document-form-values';
import {
ZDocumentMetaAllowDocumentRejectionSchema,
ZDocumentMetaDateFormatSchema,
ZDocumentMetaDistributionMethodSchema,
ZDocumentMetaDrawSignatureEnabledSchema,
@@ -96,6 +97,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
allowDictateNextSigner: z.boolean().optional(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
allowDocumentRejection: ZDocumentMetaAllowDocumentRejectionSchema.optional(),
})
.describe('Override values from the template for the created document.')
.optional(),
@@ -39,6 +39,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
drawSignatureEnabled,
defaultRecipients,
delegateDocumentOwnership,
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
@@ -167,6 +168,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
drawSignatureEnabled,
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
allowDocumentRejection,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
@@ -27,6 +27,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
drawSignatureEnabled: z.boolean().optional(),
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
allowDocumentRejection: z.boolean().optional(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
reminderSettings: ZEnvelopeReminderSettings.optional(),
@@ -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 });
});
@@ -37,6 +37,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
allowDocumentRejection,
envelopeExpirationPeriod,
reminderSettings,
@@ -169,6 +170,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
uploadSignatureEnabled,
drawSignatureEnabled,
delegateDocumentOwnership,
allowDocumentRejection,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
@@ -30,6 +30,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
uploadSignatureEnabled: z.boolean().nullish(),
drawSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
allowDocumentRejection: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
@@ -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) => {