From 36a9b88f84b38214a2bce60d360ab2764ec78339 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 17 Aug 2026 17:55:42 +0530 Subject: [PATCH 01/14] feat: support 'Allow Guest' access for pages --- frontend/src/components/PageOptions.vue | 14 +++++++++++++ frontend/src/router/app_router.ts | 8 +++++++ frontend/src/types/Studio/StudioPage.ts | 2 ++ .../studio/doctype/studio_app/studio_app.py | 17 +++++++++++---- .../doctype/studio_app/test_studio_app.py | 1 + .../doctype/studio_page/studio_page.json | 20 +++++++++++------- .../studio/doctype/studio_page/studio_page.py | 21 ++++++++++++------- studio/templates/generators/app_renderer.html | 1 + 8 files changed, 64 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/PageOptions.vue b/frontend/src/components/PageOptions.vue index c26f9e48b..93debea23 100644 --- a/frontend/src/components/PageOptions.vue +++ b/frontend/src/components/PageOptions.vue @@ -41,6 +41,18 @@ + + + + { } } if (!to.matched.length) { + if (window.is_guest) { + // guests only get public pages in app_pages — an unmatched route may just + // need a login, so bounce through it and back to the same URL + const redirectTo = encodeURIComponent(`/${window.app_route}${to.fullPath}`) + window.location.href = `/login?redirect-to=${redirectTo}` + return false + } toast.error(`Failed to navigate to ${to.fullPath}`, { description: "Page does not exist or is not published" }) diff --git a/frontend/src/types/Studio/StudioPage.ts b/frontend/src/types/Studio/StudioPage.ts index 51e791943..a2e71a8bd 100644 --- a/frontend/src/types/Studio/StudioPage.ts +++ b/frontend/src/types/Studio/StudioPage.ts @@ -13,6 +13,8 @@ export interface StudioPage { page_name: string /** Published : Check */ published?: 0 | 1 + /** Allow Guest Access : Check */ + allow_guest?: 0 | 1 /** Route : Data */ route: string /** Blocks : JSON */ diff --git a/studio/studio/doctype/studio_app/studio_app.py b/studio/studio/doctype/studio_app/studio_app.py index a7bef0638..c78f141d8 100644 --- a/studio/studio/doctype/studio_app/studio_app.py +++ b/studio/studio/doctype/studio_app/studio_app.py @@ -17,11 +17,18 @@ class StudioAppRenderer(DocumentPage): def render(self): # redirect guests to login instead of serving a dead page. - if frappe.session.user == "Guest": + if frappe.session.user == "Guest" and not self.can_render_for_guest(): frappe.flags.redirect_location = f"/login?redirect-to=/{quote(self.path)}" raise frappe.Redirect(http_status_code=302) return super().render() + def can_render_for_guest(self): + if self.is_preview(): + return False + return bool( + frappe.db.exists("Studio Page", dict(studio_app=self.docname, published=1, allow_guest=1)) + ) + def can_render(self): if app := self.find_app_for_path(): self.doctype = "Studio App" @@ -103,9 +110,11 @@ def get_context(self, context): context.app_title = self.app_title context.frappe_app = self.frappe_app or "" context.base_url = frappe.utils.get_url(self.route) - context.app_pages = frappe.get_all( - "Studio Page", dict(studio_app=self.name, published=1), ["name", "page_title", "route"] - ) + context.is_guest = frappe.session.user == "Guest" + page_filters = dict(studio_app=self.name, published=1) + if context.is_guest: + page_filters["allow_guest"] = 1 + context.app_pages = frappe.get_all("Studio Page", page_filters, ["name", "page_title", "route"]) context.is_developer_mode = frappe.utils.cint(frappe.conf.developer_mode) context.vite_dev_server_host = get_vite_dev_server_host() diff --git a/studio/studio/doctype/studio_app/test_studio_app.py b/studio/studio/doctype/studio_app/test_studio_app.py index 4452a9f21..114cae392 100644 --- a/studio/studio/doctype/studio_app/test_studio_app.py +++ b/studio/studio/doctype/studio_app/test_studio_app.py @@ -231,6 +231,7 @@ def make_studio_page(studio_app, **kwargs): "route": kwargs.get("route", "/test-page"), "blocks": kwargs.get("blocks", "[]"), "published": kwargs.get("published", 1), + "allow_guest": kwargs.get("allow_guest", 0), } ) page.insert() diff --git a/studio/studio/doctype/studio_page/studio_page.json b/studio/studio/doctype/studio_page/studio_page.json index 7882e2fcf..d086262b3 100644 --- a/studio/studio/doctype/studio_page/studio_page.json +++ b/studio/studio/doctype/studio_page/studio_page.json @@ -6,12 +6,13 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "studio_app", "page_name", "page_title", - "studio_app", + "route", "column_break_zpqw", "published", - "route", + "allow_guest", "section_break_qtyt", "blocks", "draft_blocks", @@ -20,7 +21,6 @@ "variables", "scripts_tab", "script", - "section_break_fmxt", "export_tab", "is_standard", "frappe_app" @@ -67,6 +67,14 @@ "in_standard_filter": 1, "label": "Published" }, + { + "default": "0", + "description": "Render this page for logged-out visitors. Its layout and script become publicly readable; data it fetches stays permission-checked.", + "fieldname": "allow_guest", + "fieldtype": "Check", + "in_standard_filter": 1, + "label": "Allow Guest Access" + }, { "fieldname": "draft_blocks", "fieldtype": "Long Text", @@ -109,10 +117,6 @@ "label": "Page Script", "options": "JS" }, - { - "fieldname": "section_break_fmxt", - "fieldtype": "Section Break" - }, { "fieldname": "export_tab", "fieldtype": "Tab Break", @@ -138,7 +142,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-01-08 17:26:13.130136", + "modified": "2026-08-17 17:44:03.928540", "modified_by": "Administrator", "module": "Studio", "name": "Studio Page", diff --git a/studio/studio/doctype/studio_page/studio_page.py b/studio/studio/doctype/studio_page/studio_page.py index 3a8af4e4d..bb26f48b4 100644 --- a/studio/studio/doctype/studio_page/studio_page.py +++ b/studio/studio/doctype/studio_page/studio_page.py @@ -38,6 +38,7 @@ class StudioPage(Document): from studio.studio.doctype.studio_page_resource.studio_page_resource import StudioPageResource from studio.studio.doctype.studio_page_variable.studio_page_variable import StudioPageVariable + allow_guest: DF.Check blocks: DF.LongText | None draft_blocks: DF.LongText | None frappe_app: DF.Literal[None] @@ -285,9 +286,9 @@ def save_draft(self, draft_blocks: str, known_modified: str | None = None): @frappe.whitelist() def save_page_field(self, fieldname: str, value, known_modified: str | None = None): - """Set a single editor-owned field (title/route/script) under the same optimistic lock as - save_draft, so a field edit can't silently overwrite a page the DB has moved past either.""" - FIELDS = ["page_title", "route", "script"] + """Set a single editor-owned field (title/route/script/guest access) under the same optimistic + lock as save_draft, so a field edit can't silently overwrite a page the DB has moved past either.""" + FIELDS = ["page_title", "route", "script", "allow_guest"] if fieldname not in FIELDS: frappe.throw(_("Field {0} is not editable outside the Studio editor").format(fieldname)) self.reject_if_stale(known_modified) @@ -401,25 +402,29 @@ def find_page_with_route(app_name: str, page_route: str) -> str | None: PAGE_VARIABLE_FIELDS = ("variable_name", "variable_type", "initial_value") -@frappe.whitelist(methods=["GET"]) +@frappe.whitelist(allow_guest=True, methods=["GET"]) def get_page(app_name: str, page_route: str, preview: bool = False) -> dict: """Serve a page definition to the app renderer in a single call. Published pages need no role — a published definition is markup; the data it - fetches stays permission-checked by the endpoints its resources call. Drafts - and unpublished pages are only served in preview, which requires read access - on Studio Page.""" + fetches stays permission-checked by the endpoints its resources call. Guests + only get pages that are published AND allow_guest; everything else 404s + identically so private routes can't be enumerated. Drafts and unpublished + pages are only served in preview, which requires read access on Studio Page.""" page_name = find_page_with_route(app_name, page_route) if not page_name: frappe.throw(_("Page not found"), frappe.DoesNotExistError) page = frappe.get_cached_doc("Studio Page", page_name) + is_guest = frappe.session.user == "Guest" if preview: + if is_guest: + frappe.throw(_("Page not found"), frappe.DoesNotExistError) frappe.has_permission("Studio Page", ptype="read", throw=True) blocks = page.draft_blocks or page.blocks else: # unpublished routes 404 like nonexistent ones, so the endpoint doesn't confirm they exist - if not page.published: + if not page.published or (is_guest and not page.allow_guest): frappe.throw(_("Page not found"), frappe.DoesNotExistError) blocks = page.blocks diff --git a/studio/templates/generators/app_renderer.html b/studio/templates/generators/app_renderer.html index 7ff22e935..3c7458e40 100644 --- a/studio/templates/generators/app_renderer.html +++ b/studio/templates/generators/app_renderer.html @@ -26,6 +26,7 @@ window.app_title = "{{ app_title }}"; window.frappe_app = "{{ frappe_app }}"; window.app_pages = {{ app_pages|tojson }}; + window.is_guest = {{ is_guest|tojson }}; \ No newline at end of file From d631122a7b12f5c8f22d372af5572891926a8524 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 18 Aug 2026 01:17:20 +0530 Subject: [PATCH 02/14] fix: expose component definitions to guests --- frontend/renderer.html | 1 + .../studio/doctype/studio_component/studio_component.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/renderer.html b/frontend/renderer.html index 7323c8595..2f3c4d9e5 100644 --- a/frontend/renderer.html +++ b/frontend/renderer.html @@ -19,6 +19,7 @@ window.app_title = "{{ app_title }}"; window.frappe_app = "{{ frappe_app }}"; window.app_pages = {{ app_pages|tojson }}; + window.is_guest = {{ is_guest|tojson }}; window.is_developer_mode = {{ is_developer_mode }}; {% if is_developer_mode %} diff --git a/studio/studio/doctype/studio_component/studio_component.py b/studio/studio/doctype/studio_component/studio_component.py index 94d246cec..0c19b61d5 100644 --- a/studio/studio/doctype/studio_component/studio_component.py +++ b/studio/studio/doctype/studio_component/studio_component.py @@ -58,11 +58,15 @@ def delete_component(self, studio_app: str | None = None): COMPONENT_INPUT_FIELDS = ("input_name", "type", "description", "options", "required", "default") -@frappe.whitelist(methods=["GET"]) +@frappe.whitelist(allow_guest=True, methods=["GET"]) def get_component(component_name: str) -> dict: """Serve a component definition to the app renderer without a DocType permission check — like page definitions (see get_page), a component is markup with no draft - state; the data it renders stays permission-checked by the endpoints serving it.""" + state; the data it renders stays permission-checked by the endpoints serving it. + + Deliberately guest-readable: component definitions are treated as public assets + (like the standard components shipped in the JS bundle), so guest pages can + render them without gating.""" component = frappe.get_cached_doc("Studio Component", component_name) return { "name": component.name, From e33153434e801316b6e896a0a9a42eb4fd54c749 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 18 Aug 2026 01:52:37 +0530 Subject: [PATCH 03/14] fix: ship a page's component definitions with get_page --- frontend/src/pages/AppContainer.vue | 3 + frontend/src/stores/componentStore.ts | 9 +++ frontend/src/types/Studio/StudioPage.ts | 4 ++ .../studio_component/studio_component.py | 65 ++++++++++++++++--- .../studio/doctype/studio_page/studio_page.py | 7 +- 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/AppContainer.vue b/frontend/src/pages/AppContainer.vue index 06c1fa005..99b58964f 100644 --- a/frontend/src/pages/AppContainer.vue +++ b/frontend/src/pages/AppContainer.vue @@ -14,6 +14,7 @@ import AppComponent from "@/components/AppComponent.vue" import useAppStore from "@/stores/appStore" import useCodeStore from "@/stores/codeStore" +import useComponentStore from "@/stores/componentStore" import type { StudioPage } from "@/types/Studio/StudioPage" import Block from "@/utils/block" @@ -21,6 +22,7 @@ import Block from "@/utils/block" const store = useAppStore() const route = useRoute() const codeStore = useCodeStore() +const componentStore = useComponentStore() const page = ref(null) const rootBlock = ref(null) @@ -47,6 +49,7 @@ async function loadPage() { page.value = await findPageWithRoute(window.app_name, currentPath, Boolean(window.is_preview)) if (token !== loadToken || !page.value) return + componentStore.setComponents(page.value.components || []) await store.setPageData(page.value) await codeStore.setPageScript(page.value, Boolean(page.value.is_standard)) if (token !== loadToken) return diff --git a/frontend/src/stores/componentStore.ts b/frontend/src/stores/componentStore.ts index 03bd00d75..8d083bbe8 100644 --- a/frontend/src/stores/componentStore.ts +++ b/frontend/src/stores/componentStore.ts @@ -62,6 +62,14 @@ const useComponentStore = defineStore("componentStore", () => { } } + function setComponents(componentDocs: StudioComponent[]) { + // mark everything in-flight first: caching a component instantiates its block tree, + // and nested component blocks would otherwise refetch definitions later in the list + for (const componentDoc of componentDocs) fetchingComponent.add(componentDoc.component_id) + for (const componentDoc of componentDocs) cacheComponent(componentDoc) + for (const componentDoc of componentDocs) fetchingComponent.delete(componentDoc.component_id) + } + async function reloadComponent(componentName: string) { try { cacheComponent(await fetchComponent(componentName)) @@ -110,6 +118,7 @@ const useComponentStore = defineStore("componentStore", () => { componentMap, componentDocMap, loadComponent, + setComponents, reloadComponent, getComponent, getComponentDoc, diff --git a/frontend/src/types/Studio/StudioPage.ts b/frontend/src/types/Studio/StudioPage.ts index a2e71a8bd..23af78dce 100644 --- a/frontend/src/types/Studio/StudioPage.ts +++ b/frontend/src/types/Studio/StudioPage.ts @@ -1,3 +1,5 @@ +import type { StudioComponent } from "@/types/Studio/StudioComponent" + export interface StudioPage { creation: string name: string @@ -25,5 +27,7 @@ export interface StudioPage { script?: string /** Title : Data */ page_title?: string + /** Definitions of components the served blocks use (from get_page) */ + components?: StudioComponent[] [key: string]: any } \ No newline at end of file diff --git a/studio/studio/doctype/studio_component/studio_component.py b/studio/studio/doctype/studio_component/studio_component.py index 0c19b61d5..6b777d615 100644 --- a/studio/studio/doctype/studio_component/studio_component.py +++ b/studio/studio/doctype/studio_component/studio_component.py @@ -58,16 +58,17 @@ def delete_component(self, studio_app: str | None = None): COMPONENT_INPUT_FIELDS = ("input_name", "type", "description", "options", "required", "default") -@frappe.whitelist(allow_guest=True, methods=["GET"]) +@frappe.whitelist(methods=["GET"]) def get_component(component_name: str) -> dict: - """Serve a component definition to the app renderer without a DocType permission - check — like page definitions (see get_page), a component is markup with no draft - state; the data it renders stays permission-checked by the endpoints serving it. - - Deliberately guest-readable: component definitions are treated as public assets - (like the standard components shipped in the JS bundle), so guest pages can - render them without gating.""" - component = frappe.get_cached_doc("Studio Component", component_name) + """Serve a component definition to the editor without a DocType permission + check — a component is markup with no draft state; the data it renders stays + permission-checked by the endpoints serving it. The app renderer doesn't need + this endpoint: a page ships its component definitions with get_page, which + scopes what guests can see to pages they can already fetch.""" + return get_component_data(frappe.get_cached_doc("Studio Component", component_name)) + + +def get_component_data(component) -> dict: return { "name": component.name, "component_name": component.component_name, @@ -79,3 +80,49 @@ def get_component(component_name: str) -> dict: for row in component.inputs ], } + + +def get_components_for_blocks(blocks) -> list[dict]: + """Definitions of every component a blocks tree renders, including components + nested inside other components' blocks, so the renderer gets the whole page in + one payload (see get_page).""" + pending = extract_component_names(blocks) + seen = set() + components = [] + while pending: + name = pending.pop() + if name in seen: + continue + seen.add(name) + if not frappe.db.exists("Studio Component", name): + continue # dangling reference; the renderer shows its missing-component fallback + component = frappe.get_cached_doc("Studio Component", name) + components.append(get_component_data(component)) + pending |= extract_component_names(component.block) + return components + + +def extract_component_names(blocks) -> set[str]: + """Docnames of Studio Components referenced anywhere in a blocks tree (children + slots).""" + components = set() + + def walk(block): + if not isinstance(block, dict): + return + if block.get("isStudioComponent") and block.get("componentName"): + components.add(block.get("componentName")) + for child in block.get("children") or []: + walk(child) + for slot in (block.get("componentSlots") or {}).values(): + content = slot.get("slotContent") + if isinstance(content, list): + for slot_child in content: + walk(slot_child) + + if isinstance(blocks, str): + blocks = frappe.parse_json(blocks or "[]") + if isinstance(blocks, dict): + blocks = [blocks] + for block in blocks or []: + walk(block) + return components diff --git a/studio/studio/doctype/studio_page/studio_page.py b/studio/studio/doctype/studio_page/studio_page.py index bb26f48b4..cf1fc9fd5 100644 --- a/studio/studio/doctype/studio_page/studio_page.py +++ b/studio/studio/doctype/studio_page/studio_page.py @@ -19,6 +19,7 @@ write_document_file, ) from studio.realtime import publish_doc_change +from studio.studio.doctype.studio_component.studio_component import get_components_for_blocks from studio.utils import camel_case_to_kebab_case, has_page_write_perm # A variable is referenced as {{ name }} and spread into the page's JS eval context, so its @@ -410,7 +411,10 @@ def get_page(app_name: str, page_route: str, preview: bool = False) -> dict: fetches stays permission-checked by the endpoints its resources call. Guests only get pages that are published AND allow_guest; everything else 404s identically so private routes can't be enumerated. Drafts and unpublished - pages are only served in preview, which requires read access on Studio Page.""" + pages are only served in preview, which requires read access on Studio Page. + + The served blocks' component definitions ship in the same payload, so what a + caller can see of components is exactly what the pages they can fetch use.""" page_name = find_page_with_route(app_name, page_route) if not page_name: frappe.throw(_("Page not found"), frappe.DoesNotExistError) @@ -436,6 +440,7 @@ def get_page(app_name: str, page_route: str, preview: bool = False) -> dict: "is_standard": page.is_standard, "script": page.script, "blocks": blocks, + "components": get_components_for_blocks(blocks), "resources": [ {"resource_id": row.name, **{field: row.get(field) for field in PAGE_RESOURCE_FIELDS}} for row in page.resources From c525ecdd95bccb17008cc599c86f091e25c90455 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 18 Aug 2026 01:56:01 +0530 Subject: [PATCH 04/14] refactor: drop get_component; components only ship via get_page --- frontend/src/stores/componentStore.ts | 14 +++++++------- .../doctype/studio_component/studio_component.py | 14 +++----------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/frontend/src/stores/componentStore.ts b/frontend/src/stores/componentStore.ts index 8d083bbe8..6f1e98499 100644 --- a/frontend/src/stores/componentStore.ts +++ b/frontend/src/stores/componentStore.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia" import { markRaw, reactive } from "vue" -import { createResource } from "frappe-ui" +import { createDocumentResource } from "frappe-ui" import Block from "@/utils/block" import type { StudioComponent } from "@/types/Studio/StudioComponent" import { isObjectEmpty } from "@/utils/helpers" @@ -13,13 +13,13 @@ const useComponentStore = defineStore("componentStore", () => { const fetchingComponent = reactive>(new Set()) async function fetchComponent(componentName: string) { - const componentDoc = createResource({ - url: "studio.studio.doctype.studio_component.studio_component.get_component", - method: "GET", - params: { component_name: componentName }, + const componentDoc = await createDocumentResource({ + doctype: "Studio Component", + name: componentName, + auto: true, }) - await componentDoc.fetch() - return componentDoc.data as StudioComponent + await componentDoc.get.promise + return componentDoc.doc as StudioComponent } async function getComponent(componentName: string): Promise { diff --git a/studio/studio/doctype/studio_component/studio_component.py b/studio/studio/doctype/studio_component/studio_component.py index 6b777d615..6fdcc1b2d 100644 --- a/studio/studio/doctype/studio_component/studio_component.py +++ b/studio/studio/doctype/studio_component/studio_component.py @@ -58,16 +58,6 @@ def delete_component(self, studio_app: str | None = None): COMPONENT_INPUT_FIELDS = ("input_name", "type", "description", "options", "required", "default") -@frappe.whitelist(methods=["GET"]) -def get_component(component_name: str) -> dict: - """Serve a component definition to the editor without a DocType permission - check — a component is markup with no draft state; the data it renders stays - permission-checked by the endpoints serving it. The app renderer doesn't need - this endpoint: a page ships its component definitions with get_page, which - scopes what guests can see to pages they can already fetch.""" - return get_component_data(frappe.get_cached_doc("Studio Component", component_name)) - - def get_component_data(component) -> dict: return { "name": component.name, @@ -85,7 +75,9 @@ def get_component_data(component) -> dict: def get_components_for_blocks(blocks) -> list[dict]: """Definitions of every component a blocks tree renders, including components nested inside other components' blocks, so the renderer gets the whole page in - one payload (see get_page).""" + one payload (see get_page). This is the only way component definitions reach + users without Studio roles — visibility follows page visibility; the editor + reads components through the standard document API under DocType permissions.""" pending = extract_component_names(blocks) seen = set() components = [] From 34d72118de213a2ea0283fd415a9cb407181dc6e Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 19 Aug 2026 16:01:27 +0530 Subject: [PATCH 05/14] fix: Allow Guest Access switch styles --- frontend/src/components/PageOptions.vue | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/PageOptions.vue b/frontend/src/components/PageOptions.vue index 93debea23..3f0b39bba 100644 --- a/frontend/src/components/PageOptions.vue +++ b/frontend/src/components/PageOptions.vue @@ -41,17 +41,21 @@ - +
+
+ + + + +
- +
Date: Wed, 19 Aug 2026 16:16:28 +0530 Subject: [PATCH 06/14] refactor: fetching component definitions and inputs --- studio/build.py | 26 ++-- .../studio_component/studio_component.py | 102 +++++++------ .../doctype/studio_page/test_studio_page.py | 143 +++++++++++++++++- studio/utils.py | 21 +++ 4 files changed, 221 insertions(+), 71 deletions(-) diff --git a/studio/build.py b/studio/build.py index 74f1ac1f1..a3d571c61 100644 --- a/studio/build.py +++ b/studio/build.py @@ -12,6 +12,7 @@ from frappe.utils import get_files_path from studio.constants import DEFAULT_COMPONENTS, NON_VUE_COMPONENTS +from studio.utils import walk_blocks ANSI_ESCAPE_REGEX = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]") @@ -195,23 +196,14 @@ def _add_h_function_components(self, text: str) -> None: for match in matches: self.components.add(match) - def _add_block_components(self, block: dict) -> None: - if block.get("isStudioComponent"): - self._add_studio_components(block) - elif block.get("isCustomVueComponent"): - self._add_custom_vue_component(block.get("componentName")) - elif block.get("componentName") not in NON_VUE_COMPONENTS: - self.components.add(block.get("componentName")) - for child in block.get("children", []): - self._add_block_components(child) - - if slots := block.get("componentSlots"): - for slot in slots.values(): - content = slot.get("slotContent") - if not isinstance(content, list): - continue - for slot_child in content: - self._add_block_components(slot_child) + def _add_block_components(self, blocks) -> None: + for block in walk_blocks(blocks): + if block.get("isStudioComponent"): + self._add_studio_components(block) + elif block.get("isCustomVueComponent"): + self._add_custom_vue_component(block.get("componentName")) + elif block.get("componentName") not in NON_VUE_COMPONENTS: + self.components.add(block.get("componentName")) def _add_studio_components(self, block: dict): if self.is_standard: diff --git a/studio/studio/doctype/studio_component/studio_component.py b/studio/studio/doctype/studio_component/studio_component.py index 6fdcc1b2d..e4fd4abd1 100644 --- a/studio/studio/doctype/studio_component/studio_component.py +++ b/studio/studio/doctype/studio_component/studio_component.py @@ -9,6 +9,7 @@ from studio.export import delete_file, parse_json from studio.realtime import publish_doc_change +from studio.utils import walk_blocks class StudioComponent(Document): @@ -58,63 +59,64 @@ def delete_component(self, studio_app: str | None = None): COMPONENT_INPUT_FIELDS = ("input_name", "type", "description", "options", "required", "default") -def get_component_data(component) -> dict: - return { - "name": component.name, - "component_name": component.component_name, - "component_id": component.component_id, - "block": component.block, - "is_disabled": component.is_disabled, - "inputs": [ - {"name": row.name, **{field: row.get(field) for field in COMPONENT_INPUT_FIELDS}} - for row in component.inputs - ], - } - - def get_components_for_blocks(blocks) -> list[dict]: """Definitions of every component a blocks tree renders, including components nested inside other components' blocks, so the renderer gets the whole page in one payload (see get_page). This is the only way component definitions reach users without Studio roles — visibility follows page visibility; the editor - reads components through the standard document API under DocType permissions.""" - pending = extract_component_names(blocks) - seen = set() + reads components through the standard document API under DocType permissions. + + Fetched in bulk, one round per nesting level, so queries scale with component + depth rather than component count.""" components = [] - while pending: - name = pending.pop() - if name in seen: - continue - seen.add(name) - if not frappe.db.exists("Studio Component", name): - continue # dangling reference; the renderer shows its missing-component fallback - component = frappe.get_cached_doc("Studio Component", name) - components.append(get_component_data(component)) - pending |= extract_component_names(component.block) + requested = set() + to_fetch = extract_component_names(blocks) + while to_fetch: + # dangling references drop out of the fetch; the renderer shows its + # missing-component fallback for them + components += fetch_component_batch(to_fetch) + requested |= to_fetch + to_fetch = nested_component_names(components) - requested return components -def extract_component_names(blocks) -> set[str]: - """Docnames of Studio Components referenced anywhere in a blocks tree (children + slots).""" - components = set() - - def walk(block): - if not isinstance(block, dict): - return - if block.get("isStudioComponent") and block.get("componentName"): - components.add(block.get("componentName")) - for child in block.get("children") or []: - walk(child) - for slot in (block.get("componentSlots") or {}).values(): - content = slot.get("slotContent") - if isinstance(content, list): - for slot_child in content: - walk(slot_child) - - if isinstance(blocks, str): - blocks = frappe.parse_json(blocks or "[]") - if isinstance(blocks, dict): - blocks = [blocks] - for block in blocks or []: - walk(block) +def nested_component_names(components) -> set[str]: + """Component names referenced inside the given components' own blocks.""" + names = set() + for component in components: + names |= extract_component_names(component["block"]) + return names + + +def fetch_component_batch(names: set[str]) -> list[dict]: + """One query for the component docs, one for all their input rows.""" + components = frappe.get_all( + "Studio Component", + filters={"name": ["in", names]}, + fields=["name", "component_name", "component_id", "block", "is_disabled"], + ) + if not components: + return [] + + inputs_by_component = {} + input_rows = frappe.get_all( + "Studio Component Input", + filters={"parenttype": "Studio Component", "parent": ["in", [c.name for c in components]]}, + fields=["name", "parent", *COMPONENT_INPUT_FIELDS], + order_by="idx asc", + ) + for row in input_rows: + inputs_by_component.setdefault(row.pop("parent"), []).append(row) + + for component in components: + component["inputs"] = inputs_by_component.get(component.name, []) return components + + +def extract_component_names(blocks) -> set[str]: + """Docnames of Studio Components referenced anywhere in a blocks tree.""" + return { + block["componentName"] + for block in walk_blocks(blocks) + if block.get("isStudioComponent") and block.get("componentName") + } diff --git a/studio/studio/doctype/studio_page/test_studio_page.py b/studio/studio/doctype/studio_page/test_studio_page.py index e4a154534..f2f015c17 100644 --- a/studio/studio/doctype/studio_page/test_studio_page.py +++ b/studio/studio/doctype/studio_page/test_studio_page.py @@ -1,9 +1,144 @@ # Copyright (c) 2024, Frappe Technologies Pvt Ltd and Contributors # See license.txt -# import frappe -from frappe.tests.utils import FrappeTestCase +import frappe +from frappe.tests import IntegrationTestCase +from studio.studio.doctype.studio_app.studio_app import StudioAppRenderer +from studio.studio.doctype.studio_app.test_studio_app import make_studio_app, make_studio_page +from studio.studio.doctype.studio_page.studio_page import get_page -class TestStudioPage(FrappeTestCase): - pass + +def make_component(component_name: str, block: dict | None = None, inputs: list[dict] | None = None): + component = frappe.new_doc("Studio Component") + component.component_name = component_name + component.block = frappe.as_json(block or {"componentName": "div", "children": []}, indent=None) + for input_row in inputs or []: + component.append("inputs", input_row) + component.insert() + return component + + +def component_ref(component) -> dict: + return {"componentName": component.name, "isStudioComponent": True, "children": []} + + +class TestGuestRendering(IntegrationTestCase): + """Guests only ever see pages that are published AND allow_guest; everything + else must be indistinguishable from a nonexistent page. Component definitions + ship with the page that uses them (see get_page), so component visibility + follows page visibility.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.delete_leftover_fixtures() + cls.app = make_studio_app(app_title="Guest Test App", app_name="guest-test-app") + # a component nested inside another, and one used only on a private page + cls.nested_card = make_component("Nested Card") + cls.hero = make_component( + "Hero Section", + block={"componentName": "div", "children": [component_ref(cls.nested_card)]}, + inputs=[{"input_name": "title", "type": "String"}], + ) + cls.secret_widget = make_component("Secret Widget") + cls.public_page = make_studio_page( + cls.app.name, + page_title="Public Page", + route="/public", + allow_guest=1, + blocks=frappe.as_json([{"componentName": "div", "children": [component_ref(cls.hero)]}]), + ) + cls.private_page = make_studio_page( + cls.app.name, + page_title="Private Page", + route="/private", + blocks=frappe.as_json([component_ref(cls.secret_widget)]), + ) + make_studio_page(cls.app.name, page_title="Draft Page", route="/draft", published=0, allow_guest=1) + # an app with published pages but nothing guest-accessible + cls.members_app = make_studio_app(app_title="Members App", app_name="members-app") + make_studio_page(cls.members_app.name, page_title="Members Home", route="/home") + + @classmethod + def delete_leftover_fixtures(cls): + """Fixtures can outlive a run (get_context commits mid-test), so recreate them cleanly.""" + for app_name in ("guest-test-app", "members-app"): + if frappe.db.exists("Studio App", app_name): + frappe.delete_doc("Studio App", app_name, force=True) + component_names = ["Hero Section", "Nested Card", "Secret Widget"] + for name in frappe.get_all( + "Studio Component", filters={"component_name": ["in", component_names]}, pluck="name" + ): + frappe.delete_doc("Studio Component", name, force=True) + + def as_guest(self): + frappe.set_user("Guest") + self.addCleanup(frappe.set_user, "Administrator") + + def test_guest_gets_public_page(self): + self.as_guest() + page = get_page(self.app.name, "/public") + self.assertEqual(page["name"], self.public_page.name) + + def test_guest_gets_404_for_anything_not_public(self): + self.as_guest() + for route in ("/private", "/draft", "/nonexistent"): + with self.assertRaises(frappe.DoesNotExistError): + get_page(self.app.name, route) + + def test_guest_preview_404s_even_for_public_pages(self): + self.as_guest() + with self.assertRaises(frappe.DoesNotExistError): + get_page(self.app.name, "/public", preview=True) + + def test_logged_in_user_gets_private_page(self): + page = get_page(self.app.name, "/private") + self.assertEqual(page["name"], self.private_page.name) + + def test_renderer_serves_guests_only_apps_with_public_pages(self): + self.as_guest() + renderer = StudioAppRenderer(path=f"{self.app.route}/public") + self.assertTrue(renderer.can_render()) + self.assertTrue(renderer.can_render_for_guest()) + + members_renderer = StudioAppRenderer(path=f"{self.members_app.route}/home") + self.assertTrue(members_renderer.can_render()) + self.assertFalse(members_renderer.can_render_for_guest()) + with self.assertRaises(frappe.Redirect): + members_renderer.render() + + def test_renderer_never_serves_previews_to_guests(self): + self.as_guest() + renderer = StudioAppRenderer(path=f"dev/{self.app.route}/public") + self.assertTrue(renderer.can_render()) + self.assertFalse(renderer.can_render_for_guest()) + with self.assertRaises(frappe.Redirect): + renderer.render() + + def test_app_pages_filtered_for_guest(self): + self.as_guest() + context = frappe._dict() + self.app.get_context(context) + self.assertTrue(context.is_guest) + self.assertEqual([page.route for page in context.app_pages], ["/public"]) + + def test_app_pages_unfiltered_for_logged_in_user(self): + context = frappe._dict() + self.app.get_context(context) + self.assertFalse(context.is_guest) + self.assertEqual({page.route for page in context.app_pages}, {"/public", "/private"}) + + def test_page_ships_its_component_definitions(self): + self.as_guest() + page = get_page(self.app.name, "/public") + # hero directly on the page, nested card only inside hero's block + components = {component["name"]: component for component in page["components"]} + self.assertEqual(set(components), {self.hero.name, self.nested_card.name}) + self.assertEqual(components[self.hero.name]["inputs"][0]["input_name"], "title") + self.assertEqual(components[self.nested_card.name]["inputs"], []) + + def test_get_page_is_guest_whitelisted(self): + # allow_guest is enforced by the API handler, so assert via the same check it runs + self.as_guest() + frappe.is_whitelisted(get_page) diff --git a/studio/utils.py b/studio/utils.py index 864503752..5d04ee94d 100644 --- a/studio/utils.py +++ b/studio/utils.py @@ -13,6 +13,27 @@ def camel_case_to_kebab_case(text, remove_spaces=False): return text +def walk_blocks(blocks): + """Yield every block dict in a blocks tree, descending into children and slot content. + Accepts a JSON string, a single block dict, or a list of blocks.""" + if isinstance(blocks, str): + blocks = frappe.parse_json(blocks or "[]") + if isinstance(blocks, dict): + blocks = [blocks] + + stack = list(blocks or []) + while stack: + block = stack.pop() + if not isinstance(block, dict): + continue + yield block + stack.extend(block.get("children") or []) + for slot in (block.get("componentSlots") or {}).values(): + content = slot.get("slotContent") + if isinstance(content, list): + stack.extend(content) + + def has_page_write_perm(message: str | None = None): """Decorator to check if user has permission to edit Studio Page. From dd47f2a7573f042e0eb38c8016a3fa5d112b65ef Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 19 Aug 2026 17:42:32 +0530 Subject: [PATCH 07/14] fix: guest access description --- .../doctype/studio_component/studio_component.py | 10 ++-------- studio/studio/doctype/studio_page/studio_page.json | 4 ++-- studio/studio/doctype/studio_page/studio_page.py | 1 - 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/studio/studio/doctype/studio_component/studio_component.py b/studio/studio/doctype/studio_component/studio_component.py index e4fd4abd1..61db233ba 100644 --- a/studio/studio/doctype/studio_component/studio_component.py +++ b/studio/studio/doctype/studio_component/studio_component.py @@ -60,20 +60,14 @@ def delete_component(self, studio_app: str | None = None): def get_components_for_blocks(blocks) -> list[dict]: - """Definitions of every component a blocks tree renders, including components - nested inside other components' blocks, so the renderer gets the whole page in - one payload (see get_page). This is the only way component definitions reach - users without Studio roles — visibility follows page visibility; the editor - reads components through the standard document API under DocType permissions. - + """Returns definitions of every studio component in block tree Fetched in bulk, one round per nesting level, so queries scale with component depth rather than component count.""" components = [] requested = set() to_fetch = extract_component_names(blocks) while to_fetch: - # dangling references drop out of the fetch; the renderer shows its - # missing-component fallback for them + # missing references drop out of the fetch components += fetch_component_batch(to_fetch) requested |= to_fetch to_fetch = nested_component_names(components) - requested diff --git a/studio/studio/doctype/studio_page/studio_page.json b/studio/studio/doctype/studio_page/studio_page.json index d086262b3..e50dd6d1b 100644 --- a/studio/studio/doctype/studio_page/studio_page.json +++ b/studio/studio/doctype/studio_page/studio_page.json @@ -69,7 +69,7 @@ }, { "default": "0", - "description": "Render this page for logged-out visitors. Its layout and script become publicly readable; data it fetches stays permission-checked.", + "description": "Render this page for logged-out visitors. Its layout and script become publicly readable; data it fetches depends on the data source permissions", "fieldname": "allow_guest", "fieldtype": "Check", "in_standard_filter": 1, @@ -142,7 +142,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-08-17 17:44:03.928540", + "modified": "2026-08-19 16:40:26.071762", "modified_by": "Administrator", "module": "Studio", "name": "Studio Page", diff --git a/studio/studio/doctype/studio_page/studio_page.py b/studio/studio/doctype/studio_page/studio_page.py index cf1fc9fd5..3d90f70ab 100644 --- a/studio/studio/doctype/studio_page/studio_page.py +++ b/studio/studio/doctype/studio_page/studio_page.py @@ -35,7 +35,6 @@ class StudioPage(Document): if TYPE_CHECKING: from frappe.types import DF - from studio.studio.doctype.studio_page_resource.studio_page_resource import StudioPageResource from studio.studio.doctype.studio_page_variable.studio_page_variable import StudioPageVariable From 80b88ba63bc940c38d4eaf0cd0068c3a2ed3eefa Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 25 Aug 2026 12:19:22 +0530 Subject: [PATCH 08/14] refactor: component traversing --- .../studio_component/studio_component.py | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/studio/studio/doctype/studio_component/studio_component.py b/studio/studio/doctype/studio_component/studio_component.py index 61db233ba..533421d5f 100644 --- a/studio/studio/doctype/studio_component/studio_component.py +++ b/studio/studio/doctype/studio_component/studio_component.py @@ -56,29 +56,35 @@ def delete_component(self, studio_app: str | None = None): delete_file(component_path) -COMPONENT_INPUT_FIELDS = ("input_name", "type", "description", "options", "required", "default") - - def get_components_for_blocks(blocks) -> list[dict]: """Returns definitions of every studio component in block tree Fetched in bulk, one round per nesting level, so queries scale with component depth rather than component count.""" components = [] - requested = set() + requested_components = set() to_fetch = extract_component_names(blocks) while to_fetch: - # missing references drop out of the fetch - components += fetch_component_batch(to_fetch) - requested |= to_fetch - to_fetch = nested_component_names(components) - requested + requested_components.update(to_fetch) + batch = fetch_component_batch(to_fetch) + components.extend(batch) + to_fetch = get_nested_component_names(batch) - requested_components return components -def nested_component_names(components) -> set[str]: +def extract_component_names(blocks) -> set[str]: + """Docnames of Studio Components referenced anywhere in a blocks tree.""" + return { + block["componentName"] + for block in walk_blocks(blocks) + if block.get("isStudioComponent") and block.get("componentName") + } + + +def get_nested_component_names(components) -> set[str]: """Component names referenced inside the given components' own blocks.""" names = set() for component in components: - names |= extract_component_names(component["block"]) + names.update(extract_component_names(component["block"])) return names @@ -96,7 +102,7 @@ def fetch_component_batch(names: set[str]) -> list[dict]: input_rows = frappe.get_all( "Studio Component Input", filters={"parenttype": "Studio Component", "parent": ["in", [c.name for c in components]]}, - fields=["name", "parent", *COMPONENT_INPUT_FIELDS], + fields=["name", "parent", "input_name", "type", "description", "options", "required", "default"], order_by="idx asc", ) for row in input_rows: @@ -105,12 +111,3 @@ def fetch_component_batch(names: set[str]) -> list[dict]: for component in components: component["inputs"] = inputs_by_component.get(component.name, []) return components - - -def extract_component_names(blocks) -> set[str]: - """Docnames of Studio Components referenced anywhere in a blocks tree.""" - return { - block["componentName"] - for block in walk_blocks(blocks) - if block.get("isStudioComponent") and block.get("componentName") - } From 4d412e690b78bd296f059e570c68ad059e992e2e Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 25 Aug 2026 12:48:40 +0530 Subject: [PATCH 09/14] fix: use standard label prop for page input options --- frontend/src/components/PageOptions.vue | 88 +++++++++++-------------- 1 file changed, 38 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/PageOptions.vue b/frontend/src/components/PageOptions.vue index 3f0b39bba..99c0fca08 100644 --- a/frontend/src/components/PageOptions.vue +++ b/frontend/src/components/PageOptions.vue @@ -1,61 +1,50 @@