-
+
{
return [
{
- label: "Set as App Home",
- icon: "lucide-home",
- condition: () => !isAppHome(page),
- onClick: () => {
- store.updateActiveApp("app_home", page.name)
- },
+ group: "Page settings",
+ hideLabel: true,
+ options: [
+ {
+ label: "Set as App Home",
+ icon: "lucide-home",
+ condition: () => !isAppHome(page),
+ onClick: () => store.updateActiveApp("app_home", page.name),
+ },
+ {
+ label: "Unpublish",
+ icon: "lucide-circle-dashed",
+ condition: () => Boolean(page.published),
+ onClick: () => store.unpublishPage(),
+ },
+ {
+ label: "Allow Guest Access",
+ icon: "lucide-globe-2",
+ switch: true,
+ switchValue: Boolean(isPageActive(page) ? store.activePage?.allow_guest : page.allow_guest),
+ onClick: (value: boolean) => store.updateActivePage("allow_guest", value ? 1 : 0),
+ },
+ ],
},
{
- label: "Duplicate",
- icon: "lucide-copy",
- onClick: () => store.duplicateAppPage(app.name, page),
- },
- {
- label: "Delete",
- icon: "lucide-trash",
- theme: "red",
- condition: () => !isAppHome(page),
- onClick: async () => {
- await store.deleteAppPage(app.name, page)
- if (isPageActive(page)) {
- router.push({
- name: "StudioPage",
- params: { appID: app.name, pageID: app.app_home },
- replace: true,
- })
- }
- },
+ group: "Actions",
+ hideLabel: true,
+ options: [
+ {
+ label: "Duplicate",
+ icon: "lucide-copy",
+ onClick: () => store.duplicateAppPage(app.name, page),
+ },
+ {
+ label: "Delete",
+ icon: "lucide-trash",
+ theme: "red",
+ condition: () => !isAppHome(page),
+ onClick: async () => {
+ await store.deleteAppPage(app.name, page)
+ if (isPageActive(page)) {
+ router.push({
+ name: "StudioPage",
+ params: { appID: app.name, pageID: app.app_home },
+ replace: true,
+ })
+ }
+ },
+ },
+ ],
},
]
}
diff --git a/frontend/src/data/studioPages.ts b/frontend/src/data/studioPages.ts
index 67bebf2f1..9abd9bbfd 100644
--- a/frontend/src/data/studioPages.ts
+++ b/frontend/src/data/studioPages.ts
@@ -3,7 +3,7 @@ import { createListResource } from "frappe-ui"
const studioPages = createListResource({
method: "GET",
doctype: "Studio Page",
- fields: ["name", "page_title", "route", "studio_app", "creation", "modified", "published"],
+ fields: ["name", "page_title", "route", "studio_app", "creation", "modified", "published", "allow_guest"],
auto: true,
cache: "pages",
orderBy: "creation asc",
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/router/app_router.ts b/frontend/src/router/app_router.ts
index f1d47afec..cf17efcf7 100644
--- a/frontend/src/router/app_router.ts
+++ b/frontend/src/router/app_router.ts
@@ -21,6 +21,7 @@ declare global {
app_name: string
app_route: string
app_pages: Page[]
+ is_guest?: boolean
}
}
@@ -58,6 +59,12 @@ router.beforeEach((to, _, next) => {
}
}
if (!to.matched.length) {
+ if (window.is_guest) {
+ // Private routes are absent for guests; retry after login.
+ 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/stores/componentStore.ts b/frontend/src/stores/componentStore.ts
index 03bd00d75..4c0f3e01b 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 {
@@ -62,6 +62,13 @@ const useComponentStore = defineStore("componentStore", () => {
}
}
+ function setComponents(componentDocs: StudioComponent[]) {
+ // Prevent nested blocks from refetching components in this batch.
+ 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 +117,7 @@ const useComponentStore = defineStore("componentStore", () => {
componentMap,
componentDocMap,
loadComponent,
+ setComponents,
reloadComponent,
getComponent,
getComponentDoc,
diff --git a/frontend/src/stores/studioStore.ts b/frontend/src/stores/studioStore.ts
index 4d43ab4bb..7b17412e9 100644
--- a/frontend/src/stores/studioStore.ts
+++ b/frontend/src/stores/studioStore.ts
@@ -292,16 +292,19 @@ const useStudioStore = defineStore("store", () => {
}
function updateActivePage(key: string, value: string | number) {
+ if (!activePage.value) return
+ const page = activePage.value
return studioPages.runDocMethod
.submit({
- name: activePage.value?.name,
+ name: page.name,
method: "save_page_field",
fieldname: key,
value: value,
- known_modified: activePage.value?.modified,
+ known_modified: page.modified,
})
.then((response: any) => {
- activePage.value![key] = value
+ if (activePage.value?.name !== page.name) return
+ activePage.value[key] = value
syncPageModified(response)
})
.catch(handlePageWriteConflict)
@@ -363,21 +366,25 @@ const useStudioStore = defineStore("store", () => {
async function unpublishPage() {
if (!activePage.value) return
+ const page = activePage.value
const confirmed = await confirm(
- `Are you sure you want to unpublish the page "${activePage.value.page_title}"? It will no longer be publicly accessible.`,
+ `Are you sure you want to unpublish the page "${page.page_title}"? It will no longer be publicly accessible.`,
)
if (!confirmed) {
return
}
return studioPages.runDocMethod.submit(
{
- name: selectedPage.value,
+ name: page.name,
method: "unpublish",
},
{
onSuccess(data: any) {
- activePage.value!.published = 0
- syncPageModified(data)
+ if (activePage.value?.name === page.name) {
+ activePage.value.published = 0
+ syncPageModified(data)
+ }
+ if (appPages.value[page.name]) appPages.value[page.name].published = 0
toast.success("Page unpublished")
},
onError(error: any) {
diff --git a/frontend/src/types/Studio/StudioPage.ts b/frontend/src/types/Studio/StudioPage.ts
index 51e791943..d4794f5d5 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
@@ -13,6 +15,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 */
@@ -23,5 +27,6 @@ export interface StudioPage {
script?: string
/** Title : Data */
page_title?: string
+ components?: StudioComponent[]
[key: string]: any
-}
\ No newline at end of file
+}
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_app/studio_app.py b/studio/studio/doctype/studio_app/studio_app.py
index d2f357d26..f21875bdc 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_component/studio_component.json b/studio/studio/doctype/studio_component/studio_component.json
index 3f043662f..7b3f0a08c 100644
--- a/studio/studio/doctype/studio_component/studio_component.json
+++ b/studio/studio/doctype/studio_component/studio_component.json
@@ -1,5 +1,6 @@
{
"actions": [],
+ "allow_bulk_edit": 1,
"allow_rename": 1,
"autoname": "field:component_id",
"creation": "2025-08-16 20:44:59.726936",
@@ -52,7 +53,7 @@
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2025-09-12 02:25:49.245875",
+ "modified": "2026-08-25 23:59:31.584994",
"modified_by": "Administrator",
"module": "Studio",
"name": "Studio Component",
@@ -70,6 +71,18 @@
"role": "System Manager",
"share": 1,
"write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Studio User",
+ "share": 1,
+ "write": 1
}
],
"row_format": "Dynamic",
diff --git a/studio/studio/doctype/studio_component/studio_component.py b/studio/studio/doctype/studio_component/studio_component.py
index 94d246cec..b7579bf75 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):
@@ -19,7 +20,6 @@ class StudioComponent(Document):
if TYPE_CHECKING:
from frappe.types import DF
-
from studio.studio.doctype.studio_component_input.studio_component_input import StudioComponentInput
block: DF.JSON | None
@@ -55,23 +55,53 @@ 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]:
+ """Fetch component definitions referenced by a block tree, one depth at a time."""
+ components = []
+ requested_components = set()
+ to_fetch = extract_component_names(blocks)
+ while to_fetch:
+ 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
-@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."""
- component = frappe.get_cached_doc("Studio Component", component_name)
+def extract_component_names(blocks) -> set[str]:
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
- ],
+ block["componentName"]
+ for block in walk_blocks(blocks)
+ if block.get("isStudioComponent") and block.get("componentName")
}
+
+
+def get_nested_component_names(components) -> set[str]:
+ names = set()
+ for component in components:
+ names.update(extract_component_names(component["block"]))
+ return names
+
+
+def fetch_component_batch(names: set[str]) -> list[dict]:
+ 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", "input_name", "type", "description", "options", "required", "default"],
+ 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
diff --git a/studio/studio/doctype/studio_page/studio_page.json b/studio/studio/doctype/studio_page/studio_page.json
index 7882e2fcf..e50dd6d1b 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 depends on the data source permissions",
+ "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-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 3a8af4e4d..714a6f54d 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
@@ -38,6 +39,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 +287,8 @@ 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"]
+ """Update an editor-owned field using the page's optimistic lock."""
+ 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,31 @@ 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.
+
+ 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)
page = frappe.get_cached_doc("Studio Page", page_name)
+ is_guest = frappe.session.user == "Guest"
if preview:
- frappe.has_permission("Studio Page", ptype="read", throw=True)
+ if not frappe.has_permission("Studio Page", ptype="read", doc=page):
+ frappe.throw(_("You do not have permission to preview this page"), frappe.PermissionError)
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
@@ -431,6 +438,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
diff --git a/studio/studio/doctype/studio_page/test_studio_page.py b/studio/studio/doctype/studio_page/test_studio_page.py
index e4a154534..d0f4fd503 100644
--- a/studio/studio/doctype/studio_page/test_studio_page.py
+++ b/studio/studio/doctype/studio_page/test_studio_page.py
@@ -1,9 +1,137 @@
# 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):
+ @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")
+ 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)
+ 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):
+ """Remove fixtures left behind when get_context commits."""
+ 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_cannot_preview_public_pages(self):
+ self.as_guest()
+ with self.assertRaisesRegex(
+ frappe.PermissionError, "You do not have permission to preview this page"
+ ):
+ 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")
+ 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):
+ self.as_guest()
+ frappe.is_whitelisted(get_page)
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 }};