Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/renderer.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 }};
</script>
{% if is_developer_mode %}
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/components/PageOptions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@
</div>
</div>

<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-1">
<label class="block text-xs text-ink-gray-5">Allow Guest Access</label>
<Tooltip
text="Render this page for logged-out visitors. Its layout and script become publicly readable."
>
<LucideInfo class="size-3 text-ink-gray-4" />
</Tooltip>
</div>
<Switch
size="sm"
:modelValue="Boolean(page.allow_guest)"
@update:modelValue="(val: boolean) => store.updateActivePage('allow_guest', val ? 1 : 0)"
/>
</div>

<!-- Dynamic Route Variables: design-time test values for params like /articles/:category -->
<CollapsibleSection
v-if="routeVariableNames.length"
Expand Down Expand Up @@ -68,6 +84,8 @@ import { computed, nextTick, ref, watch } from "vue"
import useStudioStore from "@/stores/studioStore"
import type { StudioPage } from "@/types/Studio/StudioPage"
import type { StudioApp } from "@/types/Studio/StudioApp"
import { Switch, Tooltip } from "frappe-ui"
import LucideInfo from "~icons/lucide/info"
import Input from "@/components/Input.vue"
import CollapsibleSection from "@/components/CollapsibleSection.vue"
import { getRouteVariables } from "@/utils/helpers"
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/AppContainer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ 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"

const store = useAppStore()
const route = useRoute()
const codeStore = useCodeStore()
const componentStore = useComponentStore()
const page = ref<StudioPage | null>(null)

const rootBlock = ref<Block | null>(null)
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/router/app_router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ declare global {
app_name: string
app_route: string
app_pages: Page[]
is_guest?: boolean
}
}

Expand Down Expand Up @@ -58,6 +59,13 @@ router.beforeEach((to, _, next) => {
}
}
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"
})
Expand Down
23 changes: 16 additions & 7 deletions frontend/src/stores/componentStore.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,13 +13,13 @@ const useComponentStore = defineStore("componentStore", () => {
const fetchingComponent = reactive<Set<string>>(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<Block | undefined> {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -110,6 +118,7 @@ const useComponentStore = defineStore("componentStore", () => {
componentMap,
componentDocMap,
loadComponent,
setComponents,
reloadComponent,
getComponent,
getComponentDoc,
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/types/Studio/StudioPage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { StudioComponent } from "@/types/Studio/StudioComponent"

export interface StudioPage {
creation: string
name: string
Expand All @@ -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 */
Expand All @@ -23,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
}
26 changes: 9 additions & 17 deletions studio/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]")

Expand Down Expand Up @@ -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:
Expand Down
17 changes: 13 additions & 4 deletions studio/studio/doctype/studio_app/studio_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions studio/studio/doctype/studio_app/test_studio_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
69 changes: 54 additions & 15 deletions studio/studio/doctype/studio_component/studio_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -58,20 +59,58 @@ 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 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 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()
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
return components


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 {
"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")
}
20 changes: 12 additions & 8 deletions studio/studio/doctype/studio_page/studio_page.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -20,7 +21,6 @@
"variables",
"scripts_tab",
"script",
"section_break_fmxt",
"export_tab",
"is_standard",
"frappe_app"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -109,10 +117,6 @@
"label": "Page Script",
"options": "JS"
},
{
"fieldname": "section_break_fmxt",
"fieldtype": "Section Break"
},
{
"fieldname": "export_tab",
"fieldtype": "Tab Break",
Expand All @@ -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",
Expand Down
Loading
Loading