mirror of
https://github.com/documenso/documenso.git
synced 2026-10-01 01:04:38 +10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39e034876 | ||
|
|
5ab94b86fd | ||
|
|
2140d132d7 | ||
|
|
5603a9e59d | ||
|
|
20ae849a1a | ||
|
|
d91a3e3828 | ||
|
|
6a99b40cba | ||
|
|
e1ad4a2c55 | ||
|
|
82918163c5 | ||
|
|
b97c22a607 |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
|
||||
+28
-5
@@ -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 && (
|
||||
|
||||
+63
@@ -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>
|
||||
);
|
||||
};
|
||||
+3
-1
@@ -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>
|
||||
|
||||
|
||||
+5
-1
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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<
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -63,6 +63,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
|
||||
@@ -280,6 +280,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
emailId: true,
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
allowDocumentRejection: true,
|
||||
distributionMethod: true,
|
||||
redirectUrl: true,
|
||||
language: true,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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'>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
+8
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user