Merge branch 'main' into fix/dev-hmr-full-reload

# Conflicts:
#	apps/remix/vite.config.ts
This commit is contained in:
ephraimduncan
2026-04-30 15:32:43 +00:00
28 changed files with 805 additions and 181 deletions
+2 -4
View File
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session'; import { useSession } from '@documenso/lib/client-only/providers/session';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@@ -23,10 +24,7 @@ import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signa
import { useToast } from '@documenso/ui/primitives/use-toast'; import { useToast } from '@documenso/ui/primitives/use-toast';
export const ZProfileFormSchema = z.object({ export const ZProfileFormSchema = z.object({
name: z name: ZNameSchema,
.string()
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }), signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
}); });
+3 -5
View File
@@ -16,6 +16,7 @@ import { z } from 'zod';
import communityCardsImage from '@documenso/assets/images/community-cards.png'; import communityCardsImage from '@documenso/assets/images/community-cards.png';
import { authClient } from '@documenso/auth/client'; import { authClient } from '@documenso/auth/client';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { env } from '@documenso/lib/utils/env'; import { env } from '@documenso/lib/utils/env';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
@@ -39,10 +40,7 @@ import { UserProfileTimur } from '~/components/general/user-profile-timur';
export const ZSignUpFormSchema = z export const ZSignUpFormSchema = z
.object({ .object({
name: z name: ZNameSchema,
.string()
.trim()
.min(1, { message: msg`Please enter a valid name.`.id }),
email: zEmail().min(1), email: zEmail().min(1),
password: ZPasswordSchema, password: ZPasswordSchema,
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }), signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
@@ -60,7 +58,7 @@ export const ZSignUpFormSchema = z
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = { export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`, SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`, [AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`, [AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
}; };
@@ -25,12 +25,17 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
envelopeStatus, envelopeStatus,
currentEnvelopeItem, currentEnvelopeItem,
fields, fields,
signatures,
recipients, recipients,
getRecipientColorKey, getRecipientColorKey,
setRenderError, setRenderError,
overrideSettings, overrideSettings,
} = useCurrentEnvelopeRender(); } = useCurrentEnvelopeRender();
const signaturesByFieldId = useMemo(() => {
return new Map(signatures.map((signature) => [signature.fieldId, signature]));
}, [signatures]);
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer( const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
({ stage, pageLayer }) => { ({ stage, pageLayer }) => {
createPageCanvas(stage, pageLayer); createPageCanvas(stage, pageLayer);
@@ -80,6 +85,16 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
const fieldTranslations = getClientSideFieldTranslations(i18n); const fieldTranslations = getClientSideFieldTranslations(i18n);
// Look up an inserted signature for this field. If we don't have one (e.g.
// the signatures haven't been loaded, or the field hasn't been signed yet)
// fall back to a placeholder so the field still renders something.
const insertedSignature = signaturesByFieldId.get(field.id);
const signature = insertedSignature ?? {
signatureImageAsBase64: '',
typedSignature: fieldTranslations.SIGNATURE,
};
renderField({ renderField({
scale, scale,
pageLayer: pageLayer.current, pageLayer: pageLayer.current,
@@ -91,10 +106,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
positionX: Number(field.positionX), positionX: Number(field.positionX),
positionY: Number(field.positionY), positionY: Number(field.positionY),
fieldMeta: field.fieldMeta, fieldMeta: field.fieldMeta,
signature: { signature,
signatureImageAsBase64: '',
typedSignature: fieldTranslations.SIGNATURE,
},
}, },
translations: fieldTranslations, translations: fieldTranslations,
pageWidth: unscaledViewport.width, pageWidth: unscaledViewport.width,
@@ -150,7 +162,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
}); });
pageLayer.current.batchDraw(); pageLayer.current.batchDraw();
}, [localPageFields]); }, [localPageFields, signaturesByFieldId]);
if (!currentEnvelopeItem) { if (!currentEnvelopeItem) {
return null; return null;
@@ -1,14 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Trans } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro';
import { import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
DocumentStatus,
EnvelopeType,
type Recipient,
type TemplateDirectLink,
} from '@prisma/client';
import { import {
Copy, Copy,
Download,
Edit, Edit,
FolderIcon, FolderIcon,
MoreHorizontal, MoreHorizontal,
@@ -30,6 +26,7 @@ import {
} from '@documenso/ui/primitives/dropdown-menu'; } from '@documenso/ui/primitives/dropdown-menu';
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog'; import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog'; import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog'; import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog'; import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
@@ -77,87 +74,94 @@ export const TemplatesTableActionDropdown = ({
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!canMutate} asChild> <EnvelopeDownloadDialog
<Link to={formatPath}> envelopeId={row.envelopeId}
<Edit className="mr-2 h-4 w-4" /> envelopeStatus={DocumentStatus.DRAFT}
<Trans>Edit</Trans> trigger={
</Link> <DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
</DropdownMenuItem> <div>
<Download className="mr-2 h-4 w-4" />
{canMutate && ( <Trans>Download</Trans>
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
<Pencil className="mr-2 h-4 w-4" />
<Trans>Rename</Trans>
</DropdownMenuItem>
)}
{canMutate && (
<EnvelopeDuplicateDialog
envelopeId={row.envelopeId}
envelopeType={EnvelopeType.TEMPLATE}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Copy className="mr-2 h-4 w-4" />
<Trans>Duplicate</Trans>
</div>
</DropdownMenuItem>
}
/>
)}
{canMutate && (
<TemplateDirectLinkDialog
templateId={row.id}
recipients={row.recipients}
directLink={row.directLink}
trigger={
<div
data-testid="template-direct-link"
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Share2Icon className="mr-2 h-4 w-4" />
<Trans>Direct link</Trans>
</div> </div>
} </DropdownMenuItem>
/> }
)} />
<DropdownMenuItem disabled={!canMutate} onClick={() => setMoveToFolderDialogOpen(true)}>
<FolderIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
</DropdownMenuItem>
{canMutate && ( {canMutate && (
<TemplateBulkSendDialog <>
templateId={row.id} <DropdownMenuItem asChild>
recipients={row.recipients} <Link to={formatPath}>
trigger={ <Edit className="mr-2 h-4 w-4" />
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"> <Trans>Edit</Trans>
<Upload className="mr-2 h-4 w-4" /> </Link>
<Trans>Bulk Send via CSV</Trans> </DropdownMenuItem>
</div>
}
/>
)}
{canMutate && ( <DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
<EnvelopeDeleteDialog <Pencil className="mr-2 h-4 w-4" />
id={row.envelopeId} <Trans>Rename</Trans>
type={EnvelopeType.TEMPLATE} </DropdownMenuItem>
status={DocumentStatus.DRAFT}
title={row.title} <EnvelopeDuplicateDialog
canManageDocument={canMutate} envelopeId={row.envelopeId}
onDelete={onDelete} envelopeType={EnvelopeType.TEMPLATE}
trigger={ trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}> <DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div> <div>
<Trash2 className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
<Trans>Delete</Trans> <Trans>Duplicate</Trans>
</div>
</DropdownMenuItem>
}
/>
<TemplateDirectLinkDialog
templateId={row.id}
recipients={row.recipients}
directLink={row.directLink}
trigger={
<div
data-testid="template-direct-link"
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
>
<Share2Icon className="mr-2 h-4 w-4" />
<Trans>Direct link</Trans>
</div> </div>
</DropdownMenuItem> }
} />
/>
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
<FolderIcon className="mr-2 h-4 w-4" />
<Trans>Move to Folder</Trans>
</DropdownMenuItem>
<TemplateBulkSendDialog
templateId={row.id}
recipients={row.recipients}
trigger={
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
<Upload className="mr-2 h-4 w-4" />
<Trans>Bulk Send via CSV</Trans>
</div>
}
/>
<EnvelopeDeleteDialog
id={row.envelopeId}
type={EnvelopeType.TEMPLATE}
status={DocumentStatus.DRAFT}
title={row.title}
canManageDocument={canMutate}
onDelete={onDelete}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Trash2 className="mr-2 h-4 w-4" />
<Trans>Delete</Trans>
</div>
</DropdownMenuItem>
}
/>
</>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
+9 -2
View File
@@ -20,7 +20,7 @@ export default async function handleRequest(
responseStatusCode: number, responseStatusCode: number,
responseHeaders: Headers, responseHeaders: Headers,
routerContext: EntryContext, routerContext: EntryContext,
_loadContext: AppLoadContext, loadContext: AppLoadContext,
) { ) {
let language = await langCookie.parse(request.headers.get('cookie') ?? ''); let language = await langCookie.parse(request.headers.get('cookie') ?? '');
@@ -30,6 +30,12 @@ export default async function handleRequest(
await dynamicActivate(language); await dynamicActivate(language);
// Threaded into ServerRouter so React Router applies the nonce to the
// 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;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let shellRendered = false; let shellRendered = false;
const userAgent = request.headers.get('user-agent'); const userAgent = request.headers.get('user-agent');
@@ -41,9 +47,10 @@ export default async function handleRequest(
const { pipe, abort } = renderToPipeableStream( const { pipe, abort } = renderToPipeableStream(
<I18nProvider i18n={i18n}> <I18nProvider i18n={i18n}>
<ServerRouter context={routerContext} url={request.url} /> <ServerRouter context={routerContext} url={request.url} nonce={nonce} />
</I18nProvider>, </I18nProvider>,
{ {
nonce,
[readyOption]() { [readyOption]() {
shellRendered = true; shellRendered = true;
const body = new PassThrough(); const body = new PassThrough();
+21 -8
View File
@@ -27,6 +27,7 @@ import { GenericErrorLayout } from './components/general/generic-error-layout';
import { langCookie } from './storage/lang-cookie.server'; import { langCookie } from './storage/lang-cookie.server';
import { themeSessionResolver } from './storage/theme-session.server'; import { themeSessionResolver } from './storage/theme-session.server';
import { appMetaTags } from './utils/meta'; import { appMetaTags } from './utils/meta';
import { nonce } from './utils/nonce';
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }]; export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
@@ -41,7 +42,7 @@ export function meta() {
*/ */
export const shouldRevalidate = () => false; export const shouldRevalidate = () => false;
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ context, request }: Route.LoaderArgs) {
const session = await getOptionalSession(request); const session = await getOptionalSession(request);
const { getTheme } = await themeSessionResolver(request); const { getTheme } = await themeSessionResolver(request);
@@ -67,6 +68,10 @@ export async function loader({ request }: Route.LoaderArgs) {
lang, lang,
theme: getTheme(), theme: getTheme(),
disableAnimations, disableAnimations,
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
// SSR-rendered <script>/<style> elements in this layout (and child
// routes that need it) can carry the matching nonce attribute.
nonce: context.nonce,
session: session.isAuthenticated session: session.isAuthenticated
? { ? {
user: session.user, user: session.user,
@@ -95,8 +100,14 @@ export function Layout({ children }: { children: React.ReactNode }) {
} }
export function LayoutContent({ children }: { children: React.ReactNode }) { export function LayoutContent({ children }: { children: React.ReactNode }) {
const { publicEnv, session, lang, disableAnimations, ...data } = const {
useLoaderData<typeof loader>() || {}; publicEnv,
session,
lang,
disableAnimations,
nonce: cspNonce,
...data
} = useLoaderData<typeof loader>() || {};
const [theme] = useTheme(); const [theme] = useTheme();
@@ -111,12 +122,13 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />
<Meta /> <Meta />
<Links /> <Links nonce={nonce(cspNonce)} />
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} /> <PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} nonce={nonce(cspNonce)} />
{disableAnimations && ( {disableAnimations && (
<style <style
nonce={nonce(cspNonce)}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`, __html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
}} }}
@@ -124,7 +136,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
)} )}
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */} {/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
<script>0</script> <script nonce={nonce(cspNonce)}>0</script>
</head> </head>
<body> <body>
{/* Global license banner currently disabled. Need to wait until after a few releases. */} {/* Global license banner currently disabled. Need to wait until after a few releases. */}
@@ -152,13 +164,14 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
</NuqsAdapter> </NuqsAdapter>
<script <script
nonce={nonce(cspNonce)}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`, __html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
}} }}
/> />
<ScrollRestoration /> <ScrollRestoration nonce={nonce(cspNonce)} />
<Scripts /> <Scripts nonce={nonce(cspNonce)} />
</body> </body>
</html> </html>
); );
@@ -62,6 +62,17 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
}, },
); );
const { data: fieldSignatures } = trpc.envelope.field.getSignatures.useQuery(
{
envelopeId: params.id,
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
enabled:
envelope && envelope.internalVersion === 2 && envelope.status === DocumentStatus.PENDING,
},
);
if (isLoadingEnvelope) { if (isLoadingEnvelope) {
return ( return (
<div className="flex w-screen flex-col items-center justify-center gap-2 py-64 text-foreground"> <div className="flex w-screen flex-col items-center justify-center gap-2 py-64 text-foreground">
@@ -159,6 +170,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
envelopeItems={envelope.envelopeItems} envelopeItems={envelope.envelopeItems}
token={undefined} token={undefined}
fields={envelope.fields} fields={envelope.fields}
signatures={fieldSignatures}
recipients={envelope.recipients} recipients={envelope.recipients}
overrideSettings={{ overrideSettings={{
showRecipientSigningStatus: true, showRecipientSigningStatus: true,
@@ -248,20 +248,18 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Trans>Template</Trans> <Trans>Template</Trans>
</h3> </h3>
{isOwnTeamTemplate && ( <div>
<div> <TemplatesTableActionDropdown
<TemplatesTableActionDropdown row={{
row={{ ...envelope,
...envelope, id: mapSecondaryIdToTemplateId(envelope.secondaryId),
id: mapSecondaryIdToTemplateId(envelope.secondaryId), envelopeId: envelope.id,
envelopeId: envelope.id, }}
}} teamId={team?.id}
teamId={team?.id} templateRootPath={templateRootPath}
templateRootPath={templateRootPath} onDelete={async () => navigate(templateRootPath)}
onDelete={async () => navigate(templateRootPath)} />
/> </div>
</div>
)}
</div> </div>
<p className="mt-2 px-4 text-sm text-muted-foreground"> <p className="mt-2 px-4 text-sm text-muted-foreground">
+8 -13
View File
@@ -17,19 +17,14 @@ import { EmbedRecipientExpired } from '~/components/embed/embed-recipient-expire
import type { Route } from './+types/_layout'; import type { Route } from './+types/_layout';
// Todo: (RR7) Test // Note: CSP (`frame-ancestors *`), `Referrer-Policy`, and
export function headers({ loaderHeaders }: Route.HeadersArgs) { // `X-Content-Type-Options` are now emitted globally by
const origin = loaderHeaders.get('Origin') ?? '*'; // `securityHeadersMiddleware` for any path under `/embed`. See
// `apps/remix/server/security-headers.ts`.
// Allow third parties to iframe the document. //
return { // The previous `Access-Control-Allow-*` headers here only ever applied to
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', // HTML page renders, where CORS preflight does not apply, so they were a
'Access-Control-Allow-Origin': origin, // no-op and have been dropped along with the rest of `headers()`.
'Content-Security-Policy': `frame-ancestors ${origin}`,
'Referrer-Policy': 'strict-origin-when-cross-origin',
'X-Content-Type-Options': 'nosniff',
};
}
export function loader() { export function loader() {
// SSR env variables. // SSR env variables.
+22
View File
@@ -0,0 +1,22 @@
/**
* Returns the supplied CSP nonce only when rendering on the server.
*
* Browsers strip the `nonce` attribute from `getAttribute()` after CSP
* processing for security (so reflected XSS can't read the nonce back out
* of the DOM), but React 18's hydration reads via `getAttribute` and warns
* about a mismatch when the JSX prop is non-empty:
*
* Prop `nonce` did not match. Server: "" Client: "abc..."
*
* Returning `undefined` on the client makes React treat the prop as
* "no attribute" — `shouldRemoveAttribute` short-circuits for nullish
* values (see `react-dom/cjs/react-dom.development.js` `shouldRemoveAttribute`),
* and the hydration prop-diff branch is skipped entirely.
*
* The nonce only matters at the moment the script/style is parsed by the
* browser. After that it's an inert attribute, so dropping it on the
* client has no functional impact. Subsequent dynamically-injected
* scripts inherit trust via `'strict-dynamic'`.
*/
export const nonce = (value: string | undefined): string | undefined =>
typeof window === 'undefined' ? value : '';
+33
View File
@@ -0,0 +1,33 @@
import { getContext } from 'hono/context-storage';
import type { AppLoadContext } from 'react-router';
import type { HonoEnv } from './router';
import { CSP_NONCE_KEY } from './security-headers';
/**
* Augment React Router's `AppLoadContext` so loaders, actions, and
* `entry.server` can access fields by name without casts.
*/
declare module 'react-router' {
interface AppLoadContext {
/**
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
* so it can be threaded into `<ServerRouter nonce>` and root loader
* data, which then feeds `<Scripts>`, `<Links>`, etc.
*/
nonce: string;
}
}
/**
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
* production (`hono-react-router-adapter/node`).
*
* The Hono context isn't passed directly by the adapter, so we read it via
* `hono/context-storage`, which is enabled in `server/router.ts`.
*/
export const getLoadContext = (): AppLoadContext => {
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
return { nonce };
};
+2 -1
View File
@@ -10,6 +10,7 @@ import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static'; import { serveStatic } from '@hono/node-server/serve-static';
import handle from 'hono-react-router-adapter/node'; import handle from 'hono-react-router-adapter/node';
import { getLoadContext } from './hono/server/load-context.js';
import server from './hono/server/router.js'; import server from './hono/server/router.js';
import * as build from './index.js'; import * as build from './index.js';
@@ -28,7 +29,7 @@ server.use(
}), }),
); );
const handler = handle(build, server); const handler = handle(build, server, { getLoadContext });
const port = parseInt(process.env.PORT || '3000', 10); const port = parseInt(process.env.PORT || '3000', 10);
+16
View File
@@ -29,13 +29,20 @@ import { downloadRoute } from './api/download/download';
import { filesRoute } from './api/files/files'; import { filesRoute } from './api/files/files';
import { type AppContext, appContext } from './context'; import { type AppContext, appContext } from './context';
import { appMiddleware } from './middleware'; import { appMiddleware } from './middleware';
import { securityHeadersMiddleware } from './security-headers';
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api'; import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix'; import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
// Re-export so the rollup build (entry: server/router.ts) bundles
// load-context.ts. server/main.js imports getLoadContext from the rolled-up
// output to wire it into the React Router adapter.
export { getLoadContext } from './load-context';
export interface HonoEnv { export interface HonoEnv {
Variables: RequestIdVariables & { Variables: RequestIdVariables & {
context: AppContext; context: AppContext;
logger: Logger; logger: Logger;
cspNonce: string;
}; };
} }
@@ -56,6 +63,15 @@ const fileRateLimitMiddleware = createRateLimitMiddleware(fileUploadRateLimit);
app.use(contextStorage()); app.use(contextStorage());
app.use(appContext); app.use(appContext);
/**
* Emit response security headers (CSP with per-request nonce, plus
* Referrer-Policy and X-Content-Type-Options on embed routes). Must run
* after `contextStorage()` so the nonce is readable via `getContext()` from
* `getLoadContext`, and before the React Router handler so the response
* carries the header.
*/
app.use(securityHeadersMiddleware);
/** /**
* RR7 app middleware. * RR7 app middleware.
*/ */
+169
View File
@@ -0,0 +1,169 @@
import { createMiddleware } from 'hono/factory';
import type { HonoEnv } from './router';
/**
* Paths that never render HTML and therefore do not need security headers.
*
* Browsers ignore CSP and friends on non-document responses, so we skip
* them to keep API/manifest/asset responses clean.
*/
const NON_PAGE_PATH_REGEX = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/apple-.*|\/favicon.*)/;
/**
* Embed routes serve our white-label embed UI. Customers iframe these from
* arbitrary origins, so `frame-ancestors` must be wildcard, and customer-
* supplied CSS is injected at runtime as `<style>` elements which means
* `style-src-elem` cannot be nonce-restricted on these routes.
*/
const EMBED_PATH_REGEX = /^\/embed(\/|\.data|$)/;
/**
* Auth pages reachable from inside an embed iframe during the
* reauth-as-different-account flow.
*
* `apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx`
* does `window.location.href = '/signin?...'` inside the iframe when the
* user needs to sign out and sign back in as a different account, and
* `<SignInForm>` links/navigates to `/forgot-password`, `/check-email`, and
* `/unverified-account` from there.
*
* Without `frame-ancestors *` on these routes, the customer's iframe gets
* blocked the moment the user clicks "Login" in the reauth dialog.
*
* These routes still get the strict nonced `script-src`/`style-src-elem`
* policy — only `frame-ancestors` is relaxed.
*/
const AUTH_FRAMEABLE_PATH_REGEX =
/^\/(signin|forgot-password|check-email|unverified-account)(\/|\.data|$)/;
/**
* Hono context variable name where the per-request CSP nonce is stashed.
*
* Read by `getLoadContext` (server/load-context.ts) so the nonce can be
* threaded into React Router's `<ServerRouter nonce>` and surfaced in the
* root loader for use by `<Scripts>`, `<Links>`, etc.
*/
export const CSP_NONCE_KEY = 'cspNonce' as const;
const generateNonce = () => {
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
let binary = '';
for (let i = 0; i < buf.length; i++) {
binary += String.fromCharCode(buf[i]);
}
return btoa(binary);
};
type CspPathKind = 'embed' | 'auth' | 'default';
const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) => {
// `'self'` is included alongside `'strict-dynamic'` as a fallback for
// browsers that don't understand `'strict-dynamic'`. Modern browsers
// ignore `'self'` (and other host/scheme sources) when `'strict-dynamic'`
// is present.
const directives = [
`base-uri 'self'`,
`object-src 'none'`,
`form-action 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
// PDF.js (apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx)
// creates a Web Worker via `new Worker(url)`. `'strict-dynamic'` does
// not reliably propagate to worker creation across browsers, and
// without `worker-src` the browser falls back to `script-src` which
// would block the worker. `blob:` covers libs that inline workers.
`worker-src 'self' blob:`,
// Inline `style=""` attributes cannot be nonced or hashed (CSP3 has no
// mechanism for it), and React inline styles, framer-motion, react-rnd,
// konva, etc. all rely on them. `'unsafe-inline'` for attributes is
// industry standard and does not weaken `style-src-elem`.
`style-src-attr 'unsafe-inline'`,
];
// Embeds inject customer-supplied CSS via runtime-created `<style>`
// elements (see apps/remix/app/utils/css-vars.ts). Nonce-stamping those
// would be brittle for white-label customers, so we accept
// `'unsafe-inline'` on the embed scope only. Auth pages do NOT load
// customer CSS and keep the strict nonced policy.
if (kind === 'embed') {
directives.push(`style-src-elem 'self' 'unsafe-inline'`);
} else {
directives.push(`style-src-elem 'self' 'nonce-${nonce}'`);
}
// Embed and auth routes are both reachable from inside a customer's
// iframe and therefore need `frame-ancestors *`. Every other page gets
// clickjacking protection.
if (kind === 'embed' || kind === 'auth') {
directives.push(`frame-ancestors *`);
} else {
directives.push(`frame-ancestors 'self'`);
}
return directives.join('; ');
};
const classifyPath = (path: string): CspPathKind => {
if (EMBED_PATH_REGEX.test(path)) {
return 'embed';
}
if (AUTH_FRAMEABLE_PATH_REGEX.test(path)) {
return 'auth';
}
return 'default';
};
/**
* Owns response security headers for page responses:
* `Content-Security-Policy`, plus `Referrer-Policy` and
* `X-Content-Type-Options` on embed routes (preserved from the per-route
* `headers()` export this middleware replaces).
*
* Generates a per-request CSP nonce and stashes it on the Hono context so
* `getLoadContext` (server/load-context.ts) can thread it into React
* Router for `<ServerRouter nonce>` and `<Scripts nonce>` etc.
*
* Path-aware classification:
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'` style-src-elem
* (white-label CSS injection), strict nonced script-src.
* - `auth` — wildcard `frame-ancestors` only; needed because the embed
* reauth flow redirects the iframe to `/signin` etc. Strict
* nonced script-src and style-src-elem otherwise.
* - default — strict nonced script-src and style-src-elem,
* `frame-ancestors 'self'` for clickjacking protection.
*/
export const securityHeadersMiddleware = createMiddleware<HonoEnv>(async (c, next) => {
const nonce = generateNonce();
c.set(CSP_NONCE_KEY, nonce);
await next();
const path = c.req.path;
if (NON_PAGE_PATH_REGEX.test(path)) {
return;
}
const kind = classifyPath(path);
c.res.headers.set('Content-Security-Policy', buildCspHeader({ nonce, kind }));
// Preserved from the per-route `headers()` export in
// apps/remix/app/routes/embed+/_v0+/_layout.tsx, which has been removed.
if (kind === 'embed') {
if (!c.res.headers.has('Referrer-Policy')) {
c.res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
}
if (!c.res.headers.has('X-Content-Type-Options')) {
c.res.headers.set('X-Content-Type-Options', 'nosniff');
}
}
});
+4
View File
@@ -18,6 +18,10 @@ const cMapsDir = normalizePath(path.join(pdfjsDistPath, 'cmaps'));
const honoDevServer = serverAdapter({ const honoDevServer = serverAdapter({
entry: 'server/router.ts', entry: 'server/router.ts',
getLoadContext: async () => {
const { getLoadContext } = await import('./server/load-context');
return getLoadContext();
},
exclude: [ exclude: [
// Spread the defaults but replace the /.css$/ rule so that Bull // Spread the defaults but replace the /.css$/ rule so that Bull
// Board's static CSS at /api/jobs/board/static/** passes through to Hono. // Board's static CSS at /api/jobs/board/static/** passes through to Hono.
@@ -551,3 +551,144 @@ test.describe('Organisation Templates - Adversarial', () => {
expect(titles).not.toContain(orgTemplate.title); expect(titles).not.toContain(orgTemplate.title);
}); });
}); });
// ─── API: envelope.item.getManyByToken (org template fallback) ───────────────
test.describe('Organisation Templates - envelope.item.getManyByToken API', () => {
test('should allow a sibling team member to fetch envelope items for an org template', async ({
page,
}) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: memberB.email });
const { res, json } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
teamB.id,
);
expect(res.ok()).toBeTruthy();
const items = json.result.data.json.data;
expect(Array.isArray(items)).toBe(true);
expect(items.length).toBeGreaterThan(0);
expect(items[0].envelopeId).toBe(orgTemplate.id);
});
test('should allow the owning team member to fetch envelope items (own-team path)', async ({
page,
}) => {
const { ownerA, teamA, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({ page, email: ownerA.email });
const { res, json } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
teamA.id,
);
expect(res.ok()).toBeTruthy();
const items = json.result.data.json.data;
expect(items.length).toBeGreaterThan(0);
expect(items[0].envelopeId).toBe(orgTemplate.id);
});
test('should reject a user outside the organisation', async ({ page }) => {
const { orgTemplate } = await seedOrgTemplateScenario();
const { user: outsider, team: outsiderTeam } = await seedUser();
await apiSignin({ page, email: outsider.email });
const { res } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
outsiderTeam.id,
);
expect(res.ok()).toBeFalsy();
});
test('should reject fetching items for a PRIVATE template from a sibling team', async ({
page,
}) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Private Items ${nanoid()}`,
templateType: TemplateType.PRIVATE,
},
});
await apiSignin({ page, email: memberB.email });
const { res } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: privateTemplate.id, access: { type: 'user' } },
teamB.id,
);
expect(res.ok()).toBeFalsy();
});
test('should respect document visibility for the viewer team role', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, {
createTemplateOptions: {
title: `Items Admin Only ${nanoid()}`,
templateType: TemplateType.ORGANISATION,
visibility: 'ADMIN',
},
});
// memberB is a MEMBER on teamB — must not be able to read items for an ADMIN-only template.
await apiSignin({ page, email: memberB.email });
const { res: memberRes } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: adminOnlyTemplate.id, access: { type: 'user' } },
teamB.id,
);
expect(memberRes.ok()).toBeFalsy();
await apiSignout({ page });
// ownerA is ADMIN on teamA — should succeed via the own-team path.
await apiSignin({ page, email: ownerA.email });
const { res: adminRes, json: adminJson } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: adminOnlyTemplate.id, access: { type: 'user' } },
teamA.id,
);
expect(adminRes.ok()).toBeTruthy();
expect(adminJson.result.data.json.data.length).toBeGreaterThan(0);
});
test('should reject unauthenticated callers using the user access type', async ({ page }) => {
const { orgTemplate, teamB } = await seedOrgTemplateScenario();
// No apiSignin — unauthenticated.
const { res } = await trpcQuery(
page,
'envelope.item.getManyByToken',
{ envelopeId: orgTemplate.id, access: { type: 'user' } },
teamB.id,
);
expect(res.ok()).toBeFalsy();
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZNameSchema } from '@documenso/lib/constants/auth';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
export const ZCurrentPasswordSchema = z export const ZCurrentPasswordSchema = z
@@ -36,7 +37,7 @@ export const ZPasswordSchema = z
}); });
export const ZSignUpSchema = z.object({ export const ZSignUpSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
email: zEmail(), email: zEmail(),
password: ZPasswordSchema, password: ZPasswordSchema,
signature: z.string().nullish(), signature: z.string().nullish(),
@@ -11,6 +11,18 @@ import { getRecipientColor } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope'; import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field'; import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
/**
* The signature data for an inserted signature field.
*
* Loaded separately from the envelope to avoid bloating the envelope.get response
* with potentially large base64 image payloads.
*/
export type EnvelopeRenderFieldSignature = {
fieldId: number;
signatureImageAsBase64: string | null;
typedSignature: string | null;
};
export type PageRenderData = { export type PageRenderData = {
scale: number; scale: number;
pageIndex: number; pageIndex: number;
@@ -50,6 +62,7 @@ type EnvelopeRenderProviderValue = {
currentEnvelopeItem: EnvelopeRenderItem | null; currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void; setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: Field[]; fields: Field[];
signatures: EnvelopeRenderFieldSignature[];
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[]; recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
getRecipientColorKey: (recipientId: number) => TRecipientColor; getRecipientColorKey: (recipientId: number) => TRecipientColor;
@@ -89,6 +102,15 @@ interface EnvelopeRenderProviderProps {
*/ */
fields?: Field[]; fields?: Field[];
/**
* Optional inserted signature data for signature fields.
*
* Fetched separately from the envelope to keep the envelope response lean.
* If a signature field has no entry here, the renderer will fall back to
* showing the field type placeholder.
*/
signatures?: EnvelopeRenderFieldSignature[];
/** /**
* Optional recipient used to determine the color of the fields and hover * Optional recipient used to determine the color of the fields and hover
* previews. * previews.
@@ -137,6 +159,7 @@ export const EnvelopeRenderProvider = ({
envelope, envelope,
envelopeItems: envelopeItemsFromProps, envelopeItems: envelopeItemsFromProps,
fields, fields,
signatures,
token, token,
presignToken, presignToken,
recipients = [], recipients = [],
@@ -212,6 +235,7 @@ export const EnvelopeRenderProvider = ({
currentEnvelopeItem: currentItem, currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem, setCurrentEnvelopeItem,
fields: fields ?? [], fields: fields ?? [],
signatures: signatures ?? [],
recipients, recipients,
getRecipientColorKey, getRecipientColorKey,
renderError, renderError,
+15
View File
@@ -1,8 +1,23 @@
import { z } from 'zod';
import { env } from '../utils/env'; import { env } from '../utils/env';
import { NEXT_PUBLIC_WEBAPP_URL } from './app'; import { NEXT_PUBLIC_WEBAPP_URL } from './app';
export const SALT_ROUNDS = 12; export const SALT_ROUNDS = 12;
export const URL_PATTERN = /https?:\/\/|www\./i;
/**
* Shared name schema that disallows URLs to prevent phishing via email rendering.
*/
export const ZNameSchema = z
.string()
.trim()
.min(3, { message: 'Please enter a valid name.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Name cannot contain URLs.',
});
export const IDENTITY_PROVIDER_NAME: Record<string, string> = { export const IDENTITY_PROVIDER_NAME: Record<string, string> = {
DOCUMENSO: 'Documenso', DOCUMENSO: 'Documenso',
GOOGLE: 'Google', GOOGLE: 'Google',
@@ -2,7 +2,7 @@ import crypto from 'crypto';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { ONE_DAY } from '../../constants/time'; import { ONE_HOUR } from '../../constants/time';
import { sendForgotPassword } from '../auth/send-forgot-password'; import { sendForgotPassword } from '../auth/send-forgot-password';
export const forgotPassword = async ({ email }: { email: string }) => { export const forgotPassword = async ({ email }: { email: string }) => {
@@ -41,7 +41,7 @@ export const forgotPassword = async ({ email }: { email: string }) => {
await prisma.passwordResetToken.create({ await prisma.passwordResetToken.create({
data: { data: {
token, token,
expiry: new Date(Date.now() + ONE_DAY), expiry: new Date(Date.now() + ONE_HOUR),
userId: user.id, userId: user.id,
}, },
}); });
@@ -54,6 +54,12 @@ export const updatePassword = async ({
}, },
}); });
await tx.passwordResetToken.deleteMany({
where: {
userId,
},
});
return await tx.user.update({ return await tx.user.update({
where: { where: {
id: userId, id: userId,
@@ -40,6 +40,71 @@ const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeig
}; };
}; };
/**
* The pixel ratio used when caching the signature image as an offscreen bitmap.
*
* Konva's default redraw composites the source image with low-quality scaling
* which makes signatures look blurry, especially when the source PNG is much
* larger than the field. Caching at a high pixel ratio rasterises the shape
* once into a sharp bitmap that is then reused on every redraw.
*
* Multiplied by `devicePixelRatio` to keep the cache crisp on retina displays.
*/
const SIGNATURE_IMAGE_CACHE_PIXEL_RATIO = 2;
/**
* Build a Konva.Image for a base64 signature, sized to fit within the given
* field dimensions. Works in both browser and Node.js (via skia-canvas).
*/
const createSignatureImage = (
signatureImageAsBase64: string,
fieldWidth: number,
fieldHeight: number,
): Konva.Image => {
if (typeof window !== 'undefined') {
const img = new Image();
const image = new Konva.Image({
image: img,
x: 0,
y: 0,
width: fieldWidth,
height: fieldHeight,
});
img.onload = () => {
image.setAttrs({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
// Cache the image as a high-resolution bitmap so it stays sharp on
// redraws and zoom changes instead of being re-scaled from the source PNG
// every time.
image.cache({
pixelRatio: SIGNATURE_IMAGE_CACHE_PIXEL_RATIO * (window.devicePixelRatio || 1),
});
};
img.src = signatureImageAsBase64;
return image;
}
// Node.js with skia-canvas
if (!SkiaImage) {
throw new Error('Skia image not found');
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(signatureImageAsBase64) as unknown as HTMLImageElement;
return new Konva.Image({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
};
const createFieldSignature = ( const createFieldSignature = (
field: FieldToRender, field: FieldToRender,
options: RenderFieldElementOptions, options: RenderFieldElementOptions,
@@ -67,6 +132,16 @@ const createFieldSignature = (
// Handle edit mode. // Handle edit mode.
if (mode === 'edit') { if (mode === 'edit') {
textToRender = fieldTypeName; textToRender = fieldTypeName;
// If the field has already been signed and we have the signature data
// available, render it. Otherwise leave the field type label as a placeholder.
if (field.inserted && signature?.typedSignature) {
textToRender = signature.typedSignature;
}
if (field.inserted && signature?.signatureImageAsBase64) {
return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight);
}
} }
// Handle sign mode. // Handle sign mode.
@@ -82,44 +157,7 @@ const createFieldSignature = (
} }
if (signature?.signatureImageAsBase64) { if (signature?.signatureImageAsBase64) {
if (typeof window !== 'undefined') { return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight);
// Create a new HTML Image element
const img = new Image();
const image = new Konva.Image({
image: img,
x: 0,
y: 0,
width: fieldWidth,
height: fieldHeight,
});
img.onload = () => {
image.setAttrs({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
};
img.src = signature.signatureImageAsBase64;
return image;
} else {
// Node.js with skia-canvas
if (!SkiaImage) {
throw new Error('Skia image not found');
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const img = new SkiaImage(signature?.signatureImageAsBase64) as unknown as HTMLImageElement;
const image = new Konva.Image({
image: img,
...getImageDimensions(img, fieldWidth, fieldHeight),
});
return image;
}
} }
} }
@@ -0,0 +1,65 @@
import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../../trpc';
import {
ZGetEnvelopeFieldSignaturesRequestSchema,
ZGetEnvelopeFieldSignaturesResponseSchema,
} from './get-envelope-field-signatures.types';
export const getEnvelopeFieldSignaturesRoute = authenticatedProcedure
.input(ZGetEnvelopeFieldSignaturesRequestSchema)
.output(ZGetEnvelopeFieldSignaturesResponseSchema)
.query(async ({ input, ctx }) => {
const { teamId, user } = ctx;
const { envelopeId } = input;
ctx.logger.info({
input: {
envelopeId,
},
});
// Validate the user has access to the envelope.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: envelopeId,
},
type: null,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
fields: {
where: {
inserted: true,
type: FieldType.SIGNATURE,
},
include: {
signature: true,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
const signatures = envelope.fields.map((field) => ({
fieldId: field.id,
signatureImageAsBase64: field.signature?.signatureImageAsBase64 ?? null,
typedSignature: field.signature?.typedSignature ?? null,
}));
return signatures;
});
@@ -0,0 +1,20 @@
import { z } from 'zod';
export const ZGetEnvelopeFieldSignaturesRequestSchema = z.object({
envelopeId: z.string().min(1),
});
export const ZGetEnvelopeFieldSignaturesResponseSchema = z
.object({
fieldId: z.number(),
signatureImageAsBase64: z.string().nullable(),
typedSignature: z.string().nullable(),
})
.array();
export type TGetEnvelopeFieldSignaturesRequest = z.infer<
typeof ZGetEnvelopeFieldSignaturesRequestSchema
>;
export type TGetEnvelopeFieldSignaturesResponse = z.infer<
typeof ZGetEnvelopeFieldSignaturesResponseSchema
>;
@@ -2,6 +2,7 @@ import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { getOrganisationTemplateWhereInput } from '@documenso/lib/server-only/template/get-organisation-template-by-id';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { maybeAuthenticatedProcedure } from '../trpc'; import { maybeAuthenticatedProcedure } from '../trpc';
@@ -101,7 +102,7 @@ const handleGetEnvelopeItemsByUser = async ({
userId: number; userId: number;
teamId: number; teamId: number;
}) => { }) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({ const { envelopeWhereInput, team: callerTeam } = await getEnvelopeWhereInput({
id: { id: {
type: 'envelopeId', type: 'envelopeId',
id: envelopeId, id: envelopeId,
@@ -111,7 +112,8 @@ const handleGetEnvelopeItemsByUser = async ({
teamId, teamId,
}); });
const envelope = await prisma.envelope.findUnique({ // Try the standard team-scoped access path first (owner / current team / team email).
let envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput, where: envelopeWhereInput,
include: { include: {
envelopeItems: { envelopeItems: {
@@ -122,6 +124,28 @@ const handleGetEnvelopeItemsByUser = async ({
}, },
}); });
// Fallback: if the envelope is an ORGANISATION template owned by a sibling team
// in the caller's organisation, allow read access to the items metadata.
// Mirrors the access logic used by `createDocumentFromTemplate` and the
// file-download endpoint's `checkEnvelopeFileAccess` so this route stays in
// sync with where actual file access is granted.
if (!envelope) {
envelope = await prisma.envelope.findFirst({
where: getOrganisationTemplateWhereInput({
id: { type: 'envelopeId', id: envelopeId },
organisationId: callerTeam.organisationId,
teamRole: callerTeam.currentTeamRole,
}),
include: {
envelopeItems: {
include: {
documentData: true,
},
},
},
});
}
if (!envelope) { if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, { throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope could not be found', message: 'Envelope could not be found',
@@ -15,6 +15,7 @@ import { duplicateEnvelopeRoute } from './duplicate-envelope';
import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields'; import { createEnvelopeFieldsRoute } from './envelope-fields/create-envelope-fields';
import { deleteEnvelopeFieldRoute } from './envelope-fields/delete-envelope-field'; import { deleteEnvelopeFieldRoute } from './envelope-fields/delete-envelope-field';
import { getEnvelopeFieldRoute } from './envelope-fields/get-envelope-field'; import { getEnvelopeFieldRoute } from './envelope-fields/get-envelope-field';
import { getEnvelopeFieldSignaturesRoute } from './envelope-fields/get-envelope-field-signatures';
import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fields'; import { updateEnvelopeFieldsRoute } from './envelope-fields/update-envelope-fields';
import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients'; import { createEnvelopeRecipientsRoute } from './envelope-recipients/create-envelope-recipients';
import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient'; import { deleteEnvelopeRecipientRoute } from './envelope-recipients/delete-envelope-recipient';
@@ -68,6 +69,7 @@ export const envelopeRouter = router({
}, },
field: { field: {
get: getEnvelopeFieldRoute, get: getEnvelopeFieldRoute,
getSignatures: getEnvelopeFieldSignaturesRoute,
createMany: createEnvelopeFieldsRoute, createMany: createEnvelopeFieldsRoute,
updateMany: updateEnvelopeFieldsRoute, updateMany: updateEnvelopeFieldsRoute,
delete: deleteEnvelopeFieldRoute, delete: deleteEnvelopeFieldRoute,
@@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { ZNameSchema } from '@documenso/lib/constants/auth';
export const ZFindUserSecurityAuditLogsSchema = z.object({ export const ZFindUserSecurityAuditLogsSchema = z.object({
page: z.number().optional(), page: z.number().optional(),
perPage: z.number().optional(), perPage: z.number().optional(),
@@ -8,7 +10,7 @@ export const ZFindUserSecurityAuditLogsSchema = z.object({
export type TFindUserSecurityAuditLogsSchema = z.infer<typeof ZFindUserSecurityAuditLogsSchema>; export type TFindUserSecurityAuditLogsSchema = z.infer<typeof ZFindUserSecurityAuditLogsSchema>;
export const ZUpdateProfileMutationSchema = z.object({ export const ZUpdateProfileMutationSchema = z.object({
name: z.string().min(1), name: ZNameSchema,
signature: z.string(), signature: z.string(),
}); });
+7 -3
View File
@@ -1,6 +1,7 @@
import { TeamMemberRole } from '@prisma/client'; import { TeamMemberRole } from '@prisma/client';
import { z } from 'zod'; import { z } from 'zod';
import { URL_PATTERN, ZNameSchema } from '@documenso/lib/constants/auth';
import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams'; import { PROTECTED_TEAM_URLS } from '@documenso/lib/constants/teams';
import { zEmail } from '@documenso/lib/utils/zod'; import { zEmail } from '@documenso/lib/utils/zod';
@@ -39,11 +40,14 @@ export const ZTeamNameSchema = z
.string() .string()
.trim() .trim()
.min(3, { message: 'Team name must be at least 3 characters long.' }) .min(3, { message: 'Team name must be at least 3 characters long.' })
.max(30, { message: 'Team name must not exceed 30 characters.' }); .max(30, { message: 'Team name must not exceed 30 characters.' })
.refine((value) => !URL_PATTERN.test(value), {
message: 'Team name cannot contain URLs.',
});
export const ZCreateTeamEmailVerificationMutationSchema = z.object({ export const ZCreateTeamEmailVerificationMutationSchema = z.object({
teamId: z.number(), teamId: z.number(),
name: z.string().trim().min(1, { message: 'Please enter a valid name.' }), name: ZNameSchema,
email: zEmail().trim().toLowerCase().min(1, 'Please enter a valid email.'), email: zEmail().trim().toLowerCase().min(1, 'Please enter a valid email.'),
}); });
@@ -62,7 +66,7 @@ export const ZGetTeamMembersQuerySchema = z.object({
export const ZUpdateTeamEmailMutationSchema = z.object({ export const ZUpdateTeamEmailMutationSchema = z.object({
teamId: z.number(), teamId: z.number(),
data: z.object({ data: z.object({
name: z.string().trim().min(1), name: ZNameSchema,
}), }),
}); });