diff --git a/apps/docs/content/docs/developers/examples/common-workflows.mdx b/apps/docs/content/docs/developers/examples/common-workflows.mdx index 5bdf32cdf..5cac47f79 100644 --- a/apps/docs/content/docs/developers/examples/common-workflows.mdx +++ b/apps/docs/content/docs/developers/examples/common-workflows.mdx @@ -51,6 +51,7 @@ async function createAndSendDocument( pdfBuffer: Buffer, filename: string, title: string, + externalId: string, recipients: Recipient[], ): Promise { 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}' +`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 diff --git a/apps/docs/package.json b/apps/docs/package.json index 9539148d0..7834fb7ef 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -15,7 +15,7 @@ "fumadocs-ui": "16.14.3", "lucide-react": "^0.563.0", "mermaid": "^11.12.2", - "next": "16.3.0", + "next": "^16.3.3", "next-plausible": "^3.12.5", "next-themes": "^0.4.6", "react": "^19.2.4", diff --git a/apps/openpage-api/package.json b/apps/openpage-api/package.json index 1b7c14350..8d1d1d11b 100644 --- a/apps/openpage-api/package.json +++ b/apps/openpage-api/package.json @@ -12,7 +12,7 @@ "dependencies": { "@documenso/prisma": "*", "luxon": "^3.7.2", - "next": "16.3.0" + "next": "^16.3.3" }, "devDependencies": { "@types/node": "^20", diff --git a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx index 7e381c82f..af03b216d 100644 --- a/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx +++ b/apps/remix/app/components/dialogs/template-bulk-send-dialog.tsx @@ -1,4 +1,7 @@ +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TBulkSendCsvError } from '@documenso/lib/server-only/template/validate-bulk-send-csv'; import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { @@ -15,9 +18,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; +import { Plural, Trans } from '@lingui/react/macro'; import { File as FileIcon, Upload, X } from 'lucide-react'; +import { useState } from 'react'; import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; import { z } from 'zod'; import { useCurrentTeam } from '~/providers/team'; @@ -29,6 +34,8 @@ const ZBulkSendFormSchema = z.object({ type TBulkSendFormSchema = z.infer; +type TBulkSendValidationError = TBulkSendCsvError | { type: 'UPLOAD_ERROR'; code: string }; + export type TemplateBulkSendDialogProps = { templateId: number; recipients: Array<{ email: string; name?: string | null }>; @@ -42,6 +49,9 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc const team = useCurrentTeam(); + const [open, setOpen] = useState(false); + const [validationError, setValidationError] = useState(null); + const form = useForm({ resolver: zodResolver(ZBulkSendFormSchema), defaultValues: { @@ -51,6 +61,20 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation(); + const onOpenChange = (value: boolean) => { + if (form.formState.isSubmitting) { + return; + } + + setOpen(value); + + if (!value) { + setValidationError(null); + + form.reset(); + } + }; + const onDownloadTemplate = () => { const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]); @@ -71,36 +95,44 @@ export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSucc }; const onSubmit = async (values: TBulkSendFormSchema) => { + setValidationError(null); + try { const csv = await values.file.text(); - await uploadBulkSend({ + const result = await uploadBulkSend({ templateId, teamId: team?.id, csv: csv, sendImmediately: values.sendImmediately, }); + if (!result.success) { + setValidationError(result.error); + + return; + } + toast({ title: _(msg`Success`), description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`), }); + setOpen(false); form.reset(); + onSuccess?.(); } catch (err) { console.error(err); - toast({ - title: _(msg`Error`), - description: _(msg`Failed to upload CSV. Please check the file format and try again.`), - variant: 'destructive', - }); + const error = AppError.parseError(err); + + setValidationError({ type: 'UPLOAD_ERROR', code: error.code }); } }; return ( - + {trigger ?? ( diff --git a/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx b/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx index 12180ab2a..3818cfcf3 100644 --- a/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx +++ b/apps/remix/app/components/embed/authoring/configure-document-recipients.tsx @@ -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(null); @@ -212,6 +215,7 @@ export const ConfigureDocumentRecipients = ({ control, isSubmitting }: Configure /> { diff --git a/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx new file mode 100644 index 000000000..79767f58f --- /dev/null +++ b/apps/remix/app/components/forms/2fa/two-factor-code-dialog.tsx @@ -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; + +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 `
` whose values include `totpCode` and `backupCode`. + */ +export const TwoFactorCodeDialog = ({ + open, + onOpenChange, + isSubmitting, + submitLabel, + onSubmit, +}: TwoFactorCodeDialogProps) => { + const form = useFormContext(); + + const [method, setMethod] = useState('totp'); + + const totpCodeName = 'totpCode' as Path; + const backupCodeName = 'backupCode' as Path; + + 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 ( + + + + + Two-Factor Authentication + + + + {method === 'totp' ? ( + Enter the code from your authenticator app to continue. + ) : ( + Enter one of your backup codes to continue. + )} + + + +
+ {method === 'totp' && ( + ( + + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + )} + /> + )} + + {method === 'backup' && ( + ( + + + Backup Code + + + + + + + )} + /> + )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/forms/password-setup-request-button.tsx b/apps/remix/app/components/forms/password-setup-request-button.tsx new file mode 100644 index 000000000..bac09f11d --- /dev/null +++ b/apps/remix/app/components/forms/password-setup-request-button.tsx @@ -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 ( + + ); + } + + return ( + + ); +}; diff --git a/apps/remix/app/components/forms/password-setup-request.tsx b/apps/remix/app/components/forms/password-setup-request.tsx new file mode 100644 index 000000000..35cfcaf80 --- /dev/null +++ b/apps/remix/app/components/forms/password-setup-request.tsx @@ -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 ( + + + Check your email + + + + We've sent a link to {user.email}. Follow it to set your password, then sign in again to continue. + + + + ); + } + + return ( +
+ {errorCode && ( + + + An error occurred + + + {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.`))} + + + )} + + +
+ ); +}; diff --git a/apps/remix/app/components/forms/password.tsx b/apps/remix/app/components/forms/password.tsx index d04f84131..65ba8c8c0 100644 --- a/apps/remix/app/components/forms/password.tsx +++ b/apps/remix/app/components/forms/password.tsx @@ -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; @@ -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({ 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 ( - + {/* method="post" so a pre-hydration native submit can't leak passwords into the URL. */} +
{ + + + open={isTwoFactorDialogOpen} + onOpenChange={setIsTwoFactorDialogOpen} + isSubmitting={isSubmitting} + submitLabel={Update password} + onSubmit={form.handleSubmit(onFormSubmit)} + /> ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx index 2d1b51747..1d2806fda 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-password.tsx @@ -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({ resolver: zodResolver(ZPasswordAuthFormSchema), @@ -47,6 +55,10 @@ export const DocumentSigningAuthPassword = ({ const [formErrorCode, setFormErrorCode] = useState(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 ( +
+ +
+ ); + } + + if (isPasswordSetupRequired) { + return ; + } + return (
- + {/* method="post" so a pre-hydration native submit can't leak the password into the URL. */} +
{formErrorCode && ( diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx new file mode 100644 index 000000000..b827c1cbf --- /dev/null +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-set-password.tsx @@ -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 ( +
+ {isEmailPasswordSigninEnabled ? ( + <> + + + No password set + + + + 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. + + + + + + + ) : ( + + + Password authentication unavailable + + + + 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. + + + + )} + + + + +
+ ); +}; diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 051ee562c..06bedf4b6 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -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} /> { 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(); @@ -797,6 +799,7 @@ export const EnvelopeEditorRecipientForm = () => {
{ diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx index 8ac4fe829..77528f5ae 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx @@ -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 */}
- + {(provided) => (
{ 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} /> { let shellRendered = false; diff --git a/apps/remix/app/middleware/admin.ts b/apps/remix/app/middleware/admin.ts new file mode 100644 index 000000000..0eef2c90d --- /dev/null +++ b/apps/remix/app/middleware/admin.ts @@ -0,0 +1,13 @@ +import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { isAdmin } from '@documenso/lib/utils/is-admin'; +import { type MiddlewareFunction, redirect } from 'react-router'; + +export const adminMiddleware: MiddlewareFunction = async ({ request }, next) => { + const { user } = await getOptionalSession(request); + + if (!user || !isAdmin(user)) { + throw redirect('/'); + } + + return next(); +}; diff --git a/apps/remix/app/middleware/nonce.ts b/apps/remix/app/middleware/nonce.ts new file mode 100644 index 000000000..916d5232d --- /dev/null +++ b/apps/remix/app/middleware/nonce.ts @@ -0,0 +1,8 @@ +import type { MiddlewareFunction } from 'react-router'; + +import { getRequestNonce } from '../../server/load-context'; +import { nonceContext } from '../utils/nonce'; + +export const nonceMiddleware: MiddlewareFunction = ({ context }) => { + context.set(nonceContext, getRequestNonce()); +}; diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index baffaa6ec..e317068df 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -23,13 +23,16 @@ import { useMatches, } from 'react-router'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; +import { nonceMiddleware } from '~/middleware/nonce'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; import { GenericErrorLayout } from './components/general/generic-error-layout'; import { langCookie } from './storage/lang-cookie.server'; import { themeSessionResolver } from './storage/theme-session.server'; import { appMetaTags } from './utils/meta'; -import { nonce } from './utils/nonce'; +import { nonce, nonceContext } from './utils/nonce'; + +export const middleware = [nonceMiddleware]; export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }]; @@ -74,7 +77,7 @@ export async function loader({ context, request }: Route.LoaderArgs) { // Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all // SSR-rendered