diff --git a/README.md b/README.md
index 00840c3d..d2c09aff 100644
--- a/README.md
+++ b/README.md
@@ -198,7 +198,7 @@ Production needs the webhook. [Revalidate webhook](#revalidate-webhook) sets it
- `SANITY_API_READ_TOKEN` on the site: a Viewer token, so the site can read drafts.
- `SANITY_STUDIO_PREVIEW_ORIGIN` on the Studio: the site's origin. Default `http://localhost:3000`. `SANITY_STUDIO_WEB_PREVIEW_ORIGINS` adds more, such as deploy previews.
-- `NEXT_PUBLIC_SANITY_STUDIO_URL` on the site: the Studio's URL, so the site lets the Studio frame it and click-to-edit opens the right Studio. Default `http://localhost:3333`; `*.sanity.studio` is always allowed.
+- `NEXT_PUBLIC_SANITY_STUDIO_URL` on the site: the Studio's URL, so the site lets the Studio frame it and click-to-edit opens the right Studio. Default `http://localhost:3333`; `*.sanity.studio` and the Sanity Dashboard (`*.sanity.io`) are always allowed.
Site and Studio must read the same dataset, or the preview shows other content.
diff --git a/web/.env.example b/web/.env.example
index d70eb1a2..53522c4b 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -28,7 +28,8 @@ SANITY_API_READ_TOKEN=
SANITY_REVALIDATE_SECRET=
# Studio URL (BUILD): click-to-edit links and CSP `frame-ancestors`. Default
-# `http://localhost:3333`; `*.sanity.studio` is always allowed. In production, the
+# `http://localhost:3333`; `*.sanity.studio` and the Sanity Dashboard (`*.sanity.io`)
+# are always allowed. In production, the
# Studio's `SANITY_STUDIO_HOSTING_URL`.
# NEXT_PUBLIC_SANITY_STUDIO_URL=https://studio.your-domain.tld
diff --git a/web/next.config.ts b/web/next.config.ts
index ee0bef1f..7dfc36b9 100644
--- a/web/next.config.ts
+++ b/web/next.config.ts
@@ -14,10 +14,10 @@ function originOf(raw: string | undefined): string | null {
/**
* Origin of the hosted Studio, for `frame-ancestors` — Sanity Presentation
- * embeds this site in an iframe on the Studio domain. `*.sanity.studio` and
- * the local Studio are always allowed; a custom Studio domain is picked up
- * from `NEXT_PUBLIC_SANITY_STUDIO_URL` (the same variable the Sanity client
- * uses for stega links).
+ * embeds this site in an iframe on the Studio domain. `*.sanity.studio`, the
+ * Sanity Dashboard and the local Studio are always allowed; a custom Studio
+ * domain is picked up from `NEXT_PUBLIC_SANITY_STUDIO_URL` (the same variable
+ * the Sanity client uses for stega links).
*/
const studioOrigin = originOf(process.env.NEXT_PUBLIC_SANITY_STUDIO_URL);
@@ -101,7 +101,11 @@ const analyticsSources = {
* manifests/segments/thumbnails, analytics, and the dev server's HMR
* websocket.
* - `frame-ancestors` must stay a header (a `` CSP cannot carry it):
- * it is what lets Sanity Presentation embed the site.
+ * it is what lets Sanity Presentation embed the site. The browser checks
+ * every ancestor, not only the parent: a `*.sanity.studio` URL redirects
+ * into the Sanity Dashboard (`www.sanity.io`), which frames the Studio,
+ * which frames this site. A Studio opened from the Dashboard on its own
+ * domain sits in the same chain, so `*.sanity.io` is listed as well.
*/
function contentSecurityPolicy(): string {
const directives: Record = {
@@ -151,6 +155,7 @@ function contentSecurityPolicy(): string {
"'self'",
"http://localhost:3333",
"https://*.sanity.studio",
+ "https://*.sanity.io",
...(studioOrigin ? [studioOrigin] : []),
],
"worker-src": ["'self'", "blob:"],
diff --git a/web/src/components/sanity/DisableDraftMode.tsx b/web/src/components/sanity/DisableDraftMode.tsx
index 4e7f7d44..094323d0 100644
--- a/web/src/components/sanity/DisableDraftMode.tsx
+++ b/web/src/components/sanity/DisableDraftMode.tsx
@@ -4,16 +4,22 @@ import { useIsPresentationTool } from "next-sanity/hooks";
import { useEffect } from "react";
/**
- * Drafts are for the Presentation preview only. Once the page is confirmed to run outside the
- * Presentation tool (`false`, not the `null` "still detecting" state), Draft Mode is switched
- * off through a full navigation, so the page re-renders on the server with published content.
- * Inside Presentation (iframe or "open preview" window) nothing happens.
+ * Drafts are for the Presentation preview only. A top-level page that Presentation does not
+ * drive (`false`, not the `null` "still detecting" state) leaves Draft Mode through a full
+ * navigation, so it re-renders on the server with published content.
+ *
+ * Inside a frame or a window another page opened, `false` only means the handshake has not
+ * finished: `` reports it after one second without a connection, and the Studio
+ * can take longer (it may itself sit in the Sanity Dashboard's frame). Leaving Draft Mode there
+ * would cut Presentation off, and in its cross-site iframe the disable route cannot even clear
+ * the partitioned cookie — so nothing happens.
*/
export function DisableDraftMode() {
const isPresentationTool = useIsPresentationTool();
useEffect(() => {
if (isPresentationTool !== false) return;
+ if (window.self !== window.top || window.opener) return;
const { pathname, search, hash } = window.location;
const target = `${pathname}${search}${hash}`;
window.location.href = `/api/draft-mode/disable?redirect=${encodeURIComponent(target)}`;