From 3056374f8014b47a0148172c713f53a60f04744b Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:04:47 -0300 Subject: [PATCH] feat(web): protocol fields: browser-encrypted secrets, asset uploads Protocol-aware fields: secrets are encrypted in the browser with the server's `secrets.pub`, and file/image fields upload assets to `deco serve`. `renderField` routes secrets to the backend-aware field and edits `Lazy` fields as their `T` (the `schema-form.tsx` hunks its tests exercise; the new-blocks-editor hunks of that file are in the next PR). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/fields/enum-field.tsx | 4 +- .../sections-editor/fields/file-field.tsx | 66 ++++++++-- .../sections-editor/fields/image-field.tsx | 87 ++++++++++--- .../fields/protocol-secret-field.tsx | 121 ++++++++++++++++++ .../fields/protocol-secret.test.ts | 66 ++++++++++ .../sections-editor/fields/protocol-secret.ts | 19 +++ .../fields/secret-field.test.ts | 10 +- .../sections-editor/fields/secret-field.tsx | 6 +- .../fields/use-image-upload.ts | 23 +++- .../fields/use-serve-asset-upload.ts | 91 +++++++++++++ .../sections-editor/schema-form.tsx | 29 ++++- apps/web/src/hooks/use-file-configs.ts | 4 +- apps/web/src/sdk/hooks/use-collections.ts | 3 +- apps/web/src/sdk/hooks/use-virtual-mcp.ts | 19 ++- packages/shared/src/secret-ciphertext.ts | 92 +++++++++++++ 15 files changed, 586 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/components/sections-editor/fields/protocol-secret-field.tsx create mode 100644 apps/web/src/components/sections-editor/fields/protocol-secret.test.ts create mode 100644 apps/web/src/components/sections-editor/fields/protocol-secret.ts create mode 100644 apps/web/src/components/sections-editor/fields/use-serve-asset-upload.ts create mode 100644 packages/shared/src/secret-ciphertext.ts diff --git a/apps/web/src/components/sections-editor/fields/enum-field.tsx b/apps/web/src/components/sections-editor/fields/enum-field.tsx index cd097eda59..65931c0ccd 100644 --- a/apps/web/src/components/sections-editor/fields/enum-field.tsx +++ b/apps/web/src/components/sections-editor/fields/enum-field.tsx @@ -40,7 +40,9 @@ export function EnumField({ virtualMcpId={sandbox?.virtualMcpId} /> { + void handleFiles(e.target.files); + e.target.value = ""; + }} + /> + ) : ( + onChange(url)} + lockedConfigId={lockedConfig?.id ?? null} + /> + )} ); } diff --git a/apps/web/src/components/sections-editor/fields/image-field.tsx b/apps/web/src/components/sections-editor/fields/image-field.tsx index 12a9ace1fc..a95eefbfa1 100644 --- a/apps/web/src/components/sections-editor/fields/image-field.tsx +++ b/apps/web/src/components/sections-editor/fields/image-field.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Image01, Trash01, Upload01 } from "@untitledui/icons"; import { Button } from "@decocms/ui/components/button.tsx"; import { Input } from "@decocms/ui/components/input.tsx"; @@ -12,7 +12,14 @@ import type { FieldProps } from "./field-props"; import { basename, extension } from "./media-filename"; import { MediaTransformControls } from "./media-transform-controls"; import { isSafeImageUrl, safeImageSrc } from "./safe-image-url"; -import { useImageUpload } from "./use-image-upload"; +import { ACCEPTED_IMAGE_TYPES, useImageUpload } from "./use-image-upload"; +import { + useServeAssetSrc, + useServeAssetUpload, +} from "./use-serve-asset-upload"; + +/** How many times a thumbnail served by the site's dev app is retried. */ +const SERVED_SRC_RETRIES = 3; export function ImageField({ schema, @@ -30,7 +37,18 @@ export function ImageField({ const [imageErrored, setImageErrored] = useState(false); // An author types this field, so the value is untrusted until it has been // through `safeImageSrc`; an unsafe scheme reduces to "". - const src = safeImageSrc(strValue); + // On a connected `deco serve` (v8), `/assets/*` loads from the site's dev + // app; everywhere else this is `strValue` unchanged. + const servedSrc = useServeAssetSrc(sandbox, strValue); + // A file just uploaded to `deco serve` can 404 for a moment, until the dev + // app's file watcher sees it: a dev-app thumbnail is retried a few times + // before it shows as unavailable. + const [loadRetry, setLoadRetry] = useState(0); + const src = safeImageSrc( + loadRetry > 0 + ? `${servedSrc}${servedSrc.includes("?") ? "&" : "?"}retry=${loadRetry}` + : servedSrc, + ); const unsafe = !!strValue && !src; const fileName = strValue ? basename(strValue) : ""; const ext = fileName ? extension(fileName) : ""; @@ -46,6 +64,7 @@ export function ImageField({ function setValue(next: string) { setImageLoaded(false); setImageErrored(false); + setLoadRetry(0); onChange(next); } @@ -87,11 +106,19 @@ export function ImageField({ if (urlDraft.trim() && !isSafeImageUrl(urlDraft)) setUrlDraft(strValue); } - const { isDragging, isPending, lockedConfigId, dropProps } = useImageUpload({ - siteSlug: sandbox?.siteSlug, - onUploaded: setValue, - onNeedsPicker: () => setPickerOpen(true), - }); + const serveUpload = useServeAssetUpload(sandbox); + const fileInputRef = useRef(null); + /** Choose a file: the system file dialog for `deco serve`, else the picker. */ + const browse = () => + serveUpload ? fileInputRef.current?.click() : setPickerOpen(true); + + const { isDragging, isPending, lockedConfigId, dropProps, handleFiles } = + useImageUpload({ + siteSlug: sandbox?.siteSlug, + onUploaded: setValue, + onNeedsPicker: () => setPickerOpen(true), + serveUpload, + }); return ( // grid-cols-[minmax(0,1fr)] forces every child to be at most 100% of @@ -124,7 +151,7 @@ export function ImageField({ <>