diff --git a/.env.example b/.env.example index b1bb493ddb..68476d1b8a 100644 --- a/.env.example +++ b/.env.example @@ -252,3 +252,27 @@ NEXT_PRIVATE_PLAIN_API_KEY= # does this; the matching values there are `documenso` / `password`). # NEXT_PRIVATE_DOCUMENT_CONVERSION_USERNAME=documenso # NEXT_PRIVATE_DOCUMENT_CONVERSION_PASSWORD=password + +# [[DOS ID / ORG SYNC WEBHOOK]] +# REQUIRED FOR WEBHOOK PROCESSING: Shared secret used to verify the HMAC +# signature of DOS.Me org/team sync webhooks (x-dos-signature header). The +# webhook handler performs privileged mutations (member role grants, +# organisation deletion), so it REJECTS ALL EVENTS when this is unset. +# NEXT_PRIVATE_DOS_WEBHOOK_SECRET="" + +# [[BLOCKCHAIN ATTESTATION]] +# OPTIONAL: On-chain anchoring is only attempted when BOTH of these are set. +# Without them, anchors stay in RETRYABLE_FAILED / PERMANENT_FAILED and are +# reported as NOT_ANCHORED by the verification endpoints (they are never +# reported as confirmed on-chain). +# CROVE_ANCHOR_GATEWAY_ADDRESS="" +# CROVE_RELAYER_PRIVATE_KEY="" +# OPTIONAL: JSON-RPC endpoints for the DOS chain. Defaults to +# https://main.doschain.com when unset. +# DOS_CHAIN_RPC_URL="" +# DOS_MAINNET_RPC="" +# OPTIONAL: DOS.Me API endpoints / internal API key. +# DOS_API_URL="" +# DOS_INTERNAL_API_KEY="" +# NEXT_PRIVATE_DOS_INTERNAL_API_KEY="" + diff --git a/.github/actions/node-install/action.yml b/.github/actions/node-install/action.yml index b01a28740a..fb208e916f 100644 --- a/.github/actions/node-install/action.yml +++ b/.github/actions/node-install/action.yml @@ -2,7 +2,7 @@ name: 'Setup node' inputs: node_version: required: false - default: v22.x + default: v24.x runs: using: 'composite' diff --git a/README.md b/README.md index a1ed84f30c..ad4d7c5b9f 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Contact us if you are interested in our Enterprise plan for large organizations To run Documenso locally, you will need -- Node.js (v22 or above) +- Node.js (v24 or above) - Postgres SQL Database - Docker (optional) diff --git a/apps/docs/content/docs/self-hosting/configuration/advanced/ai-features.mdx b/apps/docs/content/docs/self-hosting/configuration/advanced/ai-features.mdx index d614d479d4..01f2caf97d 100644 --- a/apps/docs/content/docs/self-hosting/configuration/advanced/ai-features.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/advanced/ai-features.mdx @@ -32,7 +32,7 @@ Add the variables to your deployment `.env` (or secret manager): ### Option A: Service Account Key (Recommended) -```env +```bash GOOGLE_VERTEX_PROJECT_ID="" GOOGLE_VERTEX_SERVICE_ACCOUNT_KEY="" # Optional, defaults to "global" @@ -41,7 +41,7 @@ GOOGLE_VERTEX_LOCATION="global" ### Option B: Express API Key -```env +```bash GOOGLE_VERTEX_PROJECT_ID="" GOOGLE_VERTEX_API_KEY="" # Optional, defaults to "global" @@ -50,7 +50,7 @@ GOOGLE_VERTEX_LOCATION="global" ### Option C: Application Default Credentials (ADC) -```env +```bash GOOGLE_VERTEX_PROJECT_ID="" GOOGLE_VERTEX_USE_ADC="true" # Optional, defaults to "global" diff --git a/apps/docs/content/docs/self-hosting/deployment/manual.mdx b/apps/docs/content/docs/self-hosting/deployment/manual.mdx index d6dc4fda5b..70f7da640f 100644 --- a/apps/docs/content/docs/self-hosting/deployment/manual.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/manual.mdx @@ -14,8 +14,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'; ## Prerequisites -- Node.js 22 or later -- npm 11 or later +- Node.js 24 or later +- npm 11.17 or later - PostgreSQL 14 or later - A Linux server (for systemd service setup) diff --git a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx index c64bd081e6..b750699061 100644 --- a/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx +++ b/apps/docs/content/docs/self-hosting/getting-started/requirements.mdx @@ -141,8 +141,8 @@ If building from source (not using Docker images): | Requirement | Version | | ----------- | ------- | -| Node.js | 22+ | -| npm | 11+ | +| Node.js | 24+ | +| npm | 11.17+ | --- @@ -169,7 +169,7 @@ Documenso runs on: | MySQL/MariaDB | PostgreSQL-specific features required | | SQLite | Not suitable for production workloads | | MongoDB | Relational database required | -| Node.js < 22 | Modern JavaScript features required | +| Node.js < 24 | Modern JavaScript features required | --- diff --git a/apps/remix/Dockerfile.bun b/apps/remix/Dockerfile.bun deleted file mode 100644 index 973038e8a3..0000000000 --- a/apps/remix/Dockerfile.bun +++ /dev/null @@ -1,25 +0,0 @@ -FROM oven/bun:1 AS dependencies-env -COPY . /app - -FROM dependencies-env AS development-dependencies-env -COPY ./package.json bun.lockb /app/ -WORKDIR /app -RUN bun i --frozen-lockfile - -FROM dependencies-env AS production-dependencies-env -COPY ./package.json bun.lockb /app/ -WORKDIR /app -RUN bun i --production - -FROM dependencies-env AS build-env -COPY ./package.json bun.lockb /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN bun run build - -FROM dependencies-env -COPY ./package.json bun.lockb /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["bun", "run", "start"] \ No newline at end of file diff --git a/apps/remix/Dockerfile.pnpm b/apps/remix/Dockerfile.pnpm deleted file mode 100644 index 57916afc2a..0000000000 --- a/apps/remix/Dockerfile.pnpm +++ /dev/null @@ -1,26 +0,0 @@ -FROM node:20-alpine AS dependencies-env -RUN npm i -g pnpm -COPY . /app - -FROM dependencies-env AS development-dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -WORKDIR /app -RUN pnpm i --frozen-lockfile - -FROM dependencies-env AS production-dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -WORKDIR /app -RUN pnpm i --prod --frozen-lockfile - -FROM dependencies-env AS build-env -COPY ./package.json pnpm-lock.yaml /app/ -COPY --from=development-dependencies-env /app/node_modules /app/node_modules -WORKDIR /app -RUN pnpm build - -FROM dependencies-env -COPY ./package.json pnpm-lock.yaml /app/ -COPY --from=production-dependencies-env /app/node_modules /app/node_modules -COPY --from=build-env /app/build /app/build -WORKDIR /app -CMD ["pnpm", "start"] \ No newline at end of file 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 7e381c82f7..7e610f4692 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 { @@ -17,7 +20,9 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { 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/entry.server.tsx b/apps/remix/app/entry.server.tsx index 7f28001f41..895ccba936 100644 --- a/apps/remix/app/entry.server.tsx +++ b/apps/remix/app/entry.server.tsx @@ -7,10 +7,11 @@ import { createReadableStreamFromReadable } from '@react-router/node'; import { isbot } from 'isbot'; import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; -import type { AppLoadContext, EntryContext } from 'react-router'; +import type { EntryContext, RouterContextProvider } from 'react-router'; import { ServerRouter } from 'react-router'; import { langCookie } from './storage/lang-cookie.server'; +import { nonceContext } from './utils/nonce'; export const streamTimeout = 5_000; @@ -19,7 +20,7 @@ export default async function handleRequest( responseStatusCode: number, responseHeaders: Headers, routerContext: EntryContext, - loadContext: AppLoadContext, + loadContext: RouterContextProvider, ) { let language = await langCookie.parse(request.headers.get('cookie') ?? ''); @@ -33,7 +34,7 @@ export default async function handleRequest( // scripts it injects (route manifest, hydration data, module preloads). // The same nonce is also exposed to the React tree via the root loader so // our own inline scripts/styles can carry it. - const nonce = loadContext.nonce || undefined; + const nonce = loadContext.get(nonceContext) || undefined; return new Promise((resolve, reject) => { let shellRendered = false; diff --git a/apps/remix/app/middleware/admin.ts b/apps/remix/app/middleware/admin.ts new file mode 100644 index 0000000000..0eef2c90d0 --- /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 0000000000..916d5232da --- /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 e663f7b8ac..f56ba5b294 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